From 6adeb54458400915f4ef0a933df63ca0c070365b Mon Sep 17 00:00:00 2001 From: leefer Date: Sun, 26 Jul 2026 13:31:45 +0800 Subject: [PATCH] feat: redesign workspaces and stabilize screeners --- REDESIGN_PLAN.md | 56 + api_access.py | 3 + database.py | 403 +- market_insights.py | 201 +- screener.py | 496 +- server.py | 193 +- static/app.js | 2175 ++++++- static/design-system.css | 931 +++ static/index.html | 1242 ++-- static/redesign-v2.css | 8095 +++++++++++++++++++++++++ static/renovation.css | 1636 +++++ static/styles.css | 2507 ++++++++ strategy_tracking.py | 28 + tests/e2e/app-shell.spec.js | 1434 ++++- tests/test_account_access.py | 12 +- tests/test_account_data_boundaries.py | 38 +- tests/test_api_access.py | 2 + tests/test_curated_screener.py | 114 + tests/test_frontend_contract.py | 65 +- tests/test_market_insights.py | 41 + tests/test_strategy_tracking.py | 31 + 21 files changed, 18786 insertions(+), 917 deletions(-) create mode 100644 REDESIGN_PLAN.md create mode 100644 static/design-system.css create mode 100644 static/redesign-v2.css create mode 100644 static/renovation.css create mode 100644 tests/test_curated_screener.py 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) => ` -
-
${escapeHtml(row.label)}${number(row.count)} 只
-
${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 ` -
-
${level}
${escapeHtml(group.label)}${number(group.count)} 只
-
${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("") || '
没有匹配的题材
'; + `; + }).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 = `
当日操作明细

${escapeHtml(trader.name)}

${escapeHtml(trader.description || "按当日公开龙虎榜席位汇总")}

买入
${formatMoneyMillion(trader.buy_million)}
卖出
${formatMoneyMillion(trader.sell_million)}
净额
${formatMoneyMillion(trader.net_buy_million)}
-
- - - ${(trader.operations || []).map((operation) => ` +
+
股票方向涨幅买入卖出净额关联席位标签 / 上榜原因
+ + + ${(trader.operations || []).map((operation, index) => ` - + + - - - - + + + + - + `).join("")}
序号股票方向涨幅(%)买入(百万)卖出(百万)净额(百万)关联席位标签 / 上榜原因
${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 : "")}
`; @@ -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)} + ${escapeHtml(item.remark || "尚未填写")} + + `).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) => ` + + `).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) => ` + + `).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 || "查看策略条件与适用环境。")} + ${strategy.data_ready ? "数据已就绪" : `缺少 ${(strategy.missing_data || []).length} 项数据`} +
`; + }).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) => ` + ${group.fields.map((field) => ``).join("")} + `).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) => ` +
+ + + +
+ `).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(row.risk_flags.join(";"))} + `).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 @@ 小白复盘 + + +
- +

小白复盘

登录后进入你的复盘空间
@@ -34,21 +31,9 @@
-
-
- -
-

小白复盘

-
-
- -
+
+
+
上涨 -- 下跌 -- 涨停 -- @@ -89,8 +74,11 @@
-
-
-
-
-
-
+
+
+

板块轮动

- 最近 9 个交易日 · 由近到远 +

最近 9 个交易日 · 由远到近,右侧为最新交易日

-
- +
+
+ + +
+
-
-
-
-

热点轨迹

左近右远 · 点击板块查看连续性
- +
+ +
+
+
+

热点轨迹

+ 点击任意板块追踪其连续性 +
+ 每日 Top 12 热点 +
+
+ 强度高(90+) + 强度中(70–89) + 强度低(<70)
+
正在读取轮动历史
-
-

当日轮动明细

趋势、强度与涨停梯队
-
-
- - - - - - - -
排名板块趋势今日涨停昨日涨停变化强度分布最高板平均涨幅领涨股涨停股成交额
-
+ +
+
+
+

当日轮动明细

+ 趋势、强度与涨停梯队 · 点击行可联动追踪上方轨迹 +
+ -- +
+
+ + + + + + + + + +
排名板块趋势今日涨停(只)昨日涨停(只)变化(只)强度(分)最高板(板)平均涨幅(%)领涨股涨停股成交额(亿)
+
+
-
-
-
-

集合竞价中心

- -- -
-
- -
-
-
- -
竞价状态正在确认当前竞价阶段
- -
-
-
-
-
-

重点异动

优先查看市场核心与显著预期差
- +
+
+
+

集合竞价中心

--
+
+ + 竞价状态 + 正在确认当前竞价阶段 +
-
-
- - - - +
+
+ +
+
+ +
+
+

重点异动

优先查看市场核心与显著预期差
+
+ + + + +
+
+
+
+ 预期筛选 +
+ + + + +
+
+
+ +
-
- 预期筛选 -
- - - - -
-
-
- - +
+
股票方向与来源市场身份关注分预期判断竞价涨幅竞价量能
+
-
-
-
-

题材库

--
-
- - +
+
+
+

题材库

题材走势与成分股
+ --
-
-
-
-
-
全部题材0 个
-
-
-
-
选择题材查看走势与成分股
-
-
-
-

人气热榜

--
-
-
-
-
-
- - - +
+
+
+

人气热榜

+ 同花顺 × 东方财富双榜 + -- + 涨跌幅为当日行情
- -
-
-
#代码股票名称涨跌幅最新价同花顺东方财富排名变化热门概念榜单状态
- -
-
+
+
+ + + +
+ +
+ -
-
-
-

游资龙虎榜

- -- +
+ +
+
+

双榜综合榜

按双榜排名综合排序
+ +
+
+
+
-
- - -
-
-
-
- -
- - - - -
-
-
-

活跃游资

点击卡牌查看当日操作
-
-
选择一位游资查看操作明细
-
- -

待归类席位

为营业部设置游资名后,同名席位会自动合并
- 0 个 -
-
-
-
-
-
-

我的复盘

- 自选跟踪、复盘记录与明日计划 +
+
+
+

龙虎榜

+ 游资动向与席位明细 + --
-
-
-
-
-

自选跟踪

- 0 只 +
+
+ +
-
- - - -
标记代码股票名称板块操作
-
从股票详情中添加自选
+ + +
+ + + + +
+
+
+

每日明细

按公开席位归集当日游资操作
+
+
+ + + + +
+
+
+
+

活跃游资

点击卡牌查看当日操作
净买入为红 · 净卖出为绿
+
-
-
+
选择一位游资查看操作明细
+
+ +

待归类席位

为营业部设置游资名后,同名席位会自动合并
+ 0 个 +
+
+
+
+
+ +
+
+
+

我的复盘

+ 复盘日期跟随顶栏日期:-- · 自选跟踪、交易记录与明日计划 +
+ +
+
+
+
+
+

自选跟踪

0 只
+ +
+
+ + + +
标记股票所属板块今日涨幅(%)5日涨幅(%)竞价关注(分)跟踪备注操作
+
从股票详情中添加自选,持续跟踪关键标的
+
+
+
+
+

交易日志

0 条
+ +
+
+
+ + + +
日期股票动作仓位(%)盈亏(%)盈亏金额(元)情绪 / 标签交易复核操作
+
当前日期暂无交易记录,空仓也值得记录原因
+
+
+
+
+

每日复盘

- - -
+ + + +

不求面面俱到,只记录影响下一次决策的事实。

+
-
-
-

交易日志记录

0 条
- -
-
-
- - - -
日期股票动作价格数量仓位盈亏情绪 / 标签交易逻辑执行复核操作
-
暂无手工交易记录
-
-
-
-

最近记录

0 条
+
+
-
+
准备就绪 股市有风险,投资需谨慎 -- @@ -1144,6 +1445,27 @@ + +
+
自选跟踪

添加自选

+ +
+
+
+ +
+
+
+ + +
+
+
+
问天 · 观势

解读

@@ -1257,7 +1579,7 @@
- +
-- @@ -1390,7 +1712,7 @@
- +
- +
-
管理员

系统配置

+
管理员

系统配置

正在读取系统状态
diff --git a/static/redesign-v2.css b/static/redesign-v2.css new file mode 100644 index 0000000..43fab73 --- /dev/null +++ b/static/redesign-v2.css @@ -0,0 +1,8095 @@ +/* + * Stage 2-3 redesign authority layer. + * Values and composition are transferred from the approved prototypes in + * ../界面优化. Keep later page migrations in this file until stage 19 cleanup. + */ +:root { + --r2-blue: #2563eb; + --r2-blue-dark: #1d4ed8; + --r2-blue-soft: #eff4ff; + --r2-blue-line: #c7d8fb; + --r2-up: #e04536; + --r2-up-soft: #fdecea; + --r2-down: #16a34a; + --r2-down-soft: #e9f7ee; + --r2-amber: #b45309; + --r2-amber-soft: #fdf3e3; + --r2-ink: #1f2937; + --r2-sub: #6b7280; + --r2-faint: #9ca3af; + --r2-line: #e5e7eb; + --r2-line-soft: #eef0f3; + --r2-bg: #f4f5f7; + --r2-card: #fff; + --r2-radius: 10px; + --r2-shadow: 0 1px 2px rgba(16, 24, 40, .05); +} + +html, +body { + min-height: 100%; + background: var(--r2-bg); + color: var(--r2-ink); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + font-size: 13px; +} + +body { + grid-template-columns: 200px minmax(0, 1fr); + grid-template-rows: 46px minmax(0, 1fr) 30px; + padding: 0; +} + +body.sidebar-collapsed { + grid-template-columns: 64px minmax(0, 1fr); +} + +/* Shared sidebar transferred from the prototype shell. */ +.module-nav, +body.sidebar-collapsed .module-nav { + position: fixed; + inset: 0 auto 0 0; + z-index: 60; + width: 200px; + height: 100vh; + display: flex; + flex-direction: column; + padding: 0 8px 8px; + overflow-x: hidden; + overflow-y: auto; + border-right: 1px solid var(--r2-line); + background: #fff; +} + +.sidebar-brand { + min-height: 55px; + display: flex; + align-items: center; + gap: 8px; + margin: 0 -8px 7px; + padding: 0 16px; + border-bottom: 1px solid var(--r2-line-soft); + white-space: nowrap; +} + +.sidebar-brand-mark { + width: 26px; + height: 26px; + flex: 0 0 26px; + display: grid; + place-items: center; + border-radius: 7px; + background: var(--r2-blue); + color: #fff; + font-size: 14px; + font-weight: 700; +} + +.sidebar-brand strong { + color: var(--r2-ink); + font-size: 15px; + font-weight: 700; +} + +.nav-group, +body.sidebar-collapsed .module-nav .nav-group { + display: block; + margin: 0; + padding: 0; + border: 0; +} + +.nav-group + .nav-group, +body.sidebar-collapsed .module-nav .nav-group + .nav-group { + margin-top: 0; + padding-top: 0; + border: 0; +} + +.nav-group-label { + height: auto; + padding: 12px 10px 4px; + color: var(--r2-faint); + font-size: 11px; + font-weight: 400; + letter-spacing: 0; +} + +.module-nav .module-tab, +body.sidebar-collapsed .module-nav .module-tab { + width: 100%; + min-height: 34px; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + margin: 0 0 1px; + padding: 7px 10px; + border: 0; + border-radius: 7px; + background: transparent; + color: #374151; + font-size: 13px; + font-weight: 400; +} + +.module-nav .module-tab .lucide { + width: 16px; + height: 16px; + flex: 0 0 16px; + opacity: .75; + stroke-width: 1.8; +} + +.module-nav .module-tab:hover { + background: #f6f7f9; + color: var(--r2-ink); +} + +.module-nav .module-tab.active, +.module-nav .module-tab.mobile-active { + background: var(--r2-blue-soft); + color: var(--r2-blue); + font-weight: 600; +} + +.module-nav .module-tab.active::before, +.module-nav .module-tab.mobile-active::before { + display: none; +} + +.sidebar-collapse-button { + min-height: 38px; + display: flex; + align-items: center; + gap: 8px; + margin: auto -8px -8px; + padding: 10px 16px; + border: 0; + border-top: 1px solid var(--r2-line-soft); + border-radius: 0; + background: #fff; + color: var(--r2-sub); + font-size: 12px; +} + +body.sidebar-collapsed .module-nav { + width: 64px; + padding-inline: 7px; +} + +body.sidebar-collapsed .sidebar-brand { + justify-content: center; + margin-inline: -7px; + padding-inline: 0; +} + +body.sidebar-collapsed .sidebar-brand strong, +body.sidebar-collapsed .nav-group-label, +body.sidebar-collapsed .module-tab span, +body.sidebar-collapsed .sidebar-collapse-button span { + display: none; +} + +body.sidebar-collapsed .module-nav .module-tab, +body.sidebar-collapsed .sidebar-collapse-button { + justify-content: center; + padding-inline: 0; +} + +/* Prototype top bar: market tape left, restrained actions right. */ +.app-header { + position: sticky; + top: 0; + z-index: 50; + grid-column: 2; + grid-row: 1; + width: auto; + min-height: 46px; + height: 46px; + display: flex; + align-items: center; + gap: 14px; + padding: 0 16px; + border-bottom: 1px solid var(--r2-line); + background: #fff; + box-shadow: none; + backdrop-filter: none; +} + +.market-tape { + min-width: 0; + display: flex; + align-items: center; + gap: 14px; + color: var(--r2-sub); + font-size: 12px; + white-space: nowrap; +} + +.market-tape .market-item { + color: var(--r2-sub); +} + +.market-tape .market-item strong { + color: var(--r2-ink); + font-weight: 600; +} + +.market-tape .market-item.up strong { color: var(--r2-up); } +.market-tape .market-item.down strong { color: var(--r2-down); } + +.header-actions { + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + margin-left: auto; +} + +.header-date-group { + height: 30px; + display: flex; + align-items: center; + gap: 2px; + padding: 0 3px; + border: 1px solid var(--r2-line); + border-radius: 7px; + background: #fff; +} + +.header-date-group .date-input { + width: 118px; + height: 28px; + padding: 0 4px; + border: 0; + background: transparent; + color: var(--r2-ink); + font-size: 12px; +} + +.header-date-group .icon-button, +.header-actions > .icon-button, +.header-menu-button { + width: 30px; + min-height: 30px; + height: 30px; + padding: 0; + border: 1px solid var(--r2-line); + border-radius: 7px; + background: #fff; + color: #4b5563; + box-shadow: none; +} + +.header-date-group .icon-button { border: 0; } +.header-actions .icon-button:hover { border-color: var(--r2-blue-line); color: var(--r2-blue); } +.header-actions .icon-button .lucide { width: 15px; height: 15px; } + +.header-command-group { + display: flex; + align-items: center; + gap: 6px; +} + +.header-command-group .command-button { + min-height: 30px; + height: 30px; + padding: 0 10px; + border: 1px solid var(--r2-line); + border-radius: 7px; + background: #fff; + color: #374151; + font-size: 12px; + font-weight: 500; + box-shadow: none; +} + +.header-command-group .command-button.primary { + border-color: var(--r2-blue); + background: var(--r2-blue); + color: #fff; +} + +.account-menu-shell { gap: 5px; } +.account-role-badge, +.account-button { + min-height: 28px; + height: 28px; + border-radius: 6px; + font-size: 11px; +} +.account-button { padding-inline: 8px; } + +/* Main frame and collapsible market strip. */ +.app-main { + grid-column: 2; + grid-row: 2; + width: auto; + min-width: 0; + min-height: 0; + margin: 0; + padding: 0 16px 14px; + overflow: auto; +} + +.overview-strip { + min-height: 34px; + display: flex; + align-items: stretch; + margin: 0 -16px; + padding: 0 16px; + overflow: hidden; + border: 0; + border-bottom: 1px solid var(--r2-line); + border-radius: 0; + background: #fff; + box-shadow: none; +} + +.overview-strip .sentiment-block, +.overview-strip .metric { + min-width: 0; + min-height: 33px; + display: flex; + flex: 0 0 auto; + flex-direction: row; + align-items: center; + gap: 6px; + padding: 0 10px; + border: 0; +} + +.overview-strip .sentiment-block { padding-left: 0; } +.overview-strip .sentiment-block > div:last-child { + display: flex; + align-items: center; + gap: 5px; + white-space: nowrap; +} +.overview-strip .sentiment-gauge { + width: 20px; + height: 20px; + flex: 0 0 20px; + border-width: 2px; + font-size: 8.5px; +} +.overview-strip .metric-label { color: var(--r2-sub); font-size: 11px; white-space: nowrap; } +.overview-strip .sentiment-text, +.overview-strip .metric-value { color: var(--r2-ink); font-size: 12px; font-weight: 600; white-space: nowrap; } +.overview-strip .metric-value.small { font-size: 11.5px; } +.overview-strip .metric-value.up { color: var(--r2-up); } +.overview-strip .metric-value.down { color: var(--r2-down); } +.overview-strip .metric-value.warning { color: var(--r2-amber); } +.overview-toggle { + min-width: max-content; + min-height: 33px; + margin-left: auto; + padding: 0; + border: 0; + color: var(--r2-blue); + font-size: 12px; +} + +.overview-strip[data-overview-expanded="true"] { + min-height: 76px; + display: grid; + grid-template-columns: repeat(7, minmax(110px, 1fr)) auto; + padding: 0; + background: var(--r2-line-soft); + gap: 1px; +} +.overview-strip[data-overview-expanded="true"] .sentiment-block, +.overview-strip[data-overview-expanded="true"] .metric { + min-height: 76px; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 4px; + padding: 10px 16px; + background: #fff; +} +.overview-strip[data-overview-expanded="true"] .sentiment-block { flex-direction: row; align-items: center; } +.overview-strip[data-overview-expanded="true"] .sentiment-gauge { width: 46px; height: 46px; flex-basis: 46px; font-size: 13px; } +.overview-strip[data-overview-expanded="true"] .metric-value { font-size: 18px; } +.overview-strip[data-overview-expanded="true"] .overview-toggle { justify-content: center; padding: 0 12px; background: #fff; } + +.workspace-view { + margin-top: 14px; + border-color: var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} + +.status-bar { + position: fixed; + right: 0; + bottom: 0; + left: 200px; + z-index: 55; + grid-column: 2; + grid-row: 3; + width: auto; + min-height: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 16px; + border-top: 1px solid var(--r2-line); + background: #fff; + color: var(--r2-faint); + font-size: 11.5px; +} +.status-bar .risk-note { margin: 0; text-align: center; } +body.sidebar-collapsed .status-bar { left: 64px; } + +/* Stage 3: direct visual transfer of emotion.html. */ +.redesigned-sentiment-view { + overflow: visible; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.redesigned-page-head { + min-height: 34px; + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 12px; + padding: 0; + border: 0; + background: transparent; +} + +.redesigned-page-head .section-title-group { display: flex; align-items: baseline; gap: 12px; } +.redesigned-page-head .section-title-group h2 { color: var(--r2-ink); font-size: 17px; font-weight: 800; } +.redesigned-page-head .section-subtitle { color: var(--r2-faint); font-size: 12px; font-weight: 400; } +.redesigned-page-head .section-subtitle b { font-weight: 400; } +.redesigned-page-head .toolbar-controls { margin-left: auto; } +.redesigned-page-head .segmented { + min-height: 28px; + display: inline-flex; + gap: 2px; + padding: 2px; + border: 0; + border-radius: 8px; + background: #f3f4f6; +} +.redesigned-page-head .segment { + min-height: 24px; + padding: 4px 12px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--r2-sub); + font-size: 12px; +} +.redesigned-page-head .segment.active { + background: #fff; + color: var(--r2-ink); + font-weight: 600; + box-shadow: 0 1px 2px rgba(0, 0, 0, .08); +} +.redesigned-page-head #sentimentExportButton { + min-height: 28px; + height: 28px; + padding: 0 11px; + font-size: 12px; +} + +.redesigned-emotion-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 12px; + align-items: start; + margin-bottom: 12px; + border: 0; +} + +.redesigned-card { + overflow: hidden; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: var(--r2-card); + box-shadow: var(--r2-shadow); +} + +.redesigned-card-head { + min-height: 42px; + display: flex; + align-items: center; + gap: 8px; + padding: 11px 14px; + border-bottom: 1px solid var(--r2-line-soft); +} +.redesigned-card-head h3 { color: var(--r2-ink); font-size: 14px; font-weight: 700; } +.redesigned-card-head > span { color: var(--r2-faint); font-size: 11px; } +.redesigned-card-head .sentiment-current-tag, +.redesigned-card-head .sentiment-auto-tag { + margin-left: auto; + padding: 2px 7px; + border-radius: 5px; + background: #f3f4f6; + color: var(--r2-sub); + font-size: 11px; +} + +.redesigned-sentiment-view .sentiment-trend-panel, +.redesigned-sentiment-view .sentiment-components-panel, +.redesigned-sentiment-view .sentiment-cycle-summary { + margin: 0; + padding: 0; + border: 1px solid var(--r2-line); +} + +.redesigned-sentiment-view .sentiment-chart-legend { + min-height: 34px; + display: flex; + gap: 16px; + align-items: center; + padding: 8px 14px; + border-bottom: 1px solid var(--r2-line-soft); + color: var(--r2-sub); + font-size: 11px; +} +.redesigned-sentiment-view .sentiment-chart-shell { position: relative; height: 320px; padding: 14px 16px 6px; } +.redesigned-sentiment-view .sentiment-chart-shell canvas { height: 300px; } +.sentiment-chart-tooltip { + position: absolute; + z-index: 5; + pointer-events: none; + padding: 5px 9px; + border-radius: 6px; + background: var(--r2-ink); + color: #fff; + font-size: 11px; + white-space: nowrap; +} +.sentiment-chart-tooltip b { font-weight: 700; } +.redesigned-sentiment-view .sentiment-analysis-rail { display: flex; flex-direction: column; gap: 12px; } + +.sentiment-phase-block { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 14px 16px; +} +.sentiment-current-phase-badge { + min-width: 94px; + flex: 0 0 auto; + display: block; + padding: 10px 18px; + border: 1px solid #f5cfc9; + border-radius: 10px; + background: var(--r2-up-soft); + text-align: center; +} +.sentiment-current-phase-badge strong { display: block; color: var(--r2-up); font-size: 19px; font-weight: 800; line-height: 1.35; } +.sentiment-current-phase-badge span { display: block; margin-top: 2px; color: var(--r2-sub); font-size: 11px; white-space: nowrap; } +.sentiment-phase-info { min-width: 0; flex: 1; } +.sentiment-phase-info p { color: #374151; font-size: 12.5px; line-height: 1.7; } +.sentiment-phase-info p b { font-weight: 700; } +.sentiment-phase-info p .down { color: var(--r2-down); } +.sentiment-phase-info p .up { color: var(--r2-up); } +.sentiment-phase-advice { + margin-top: 8px; + padding: 5px 9px; + border-radius: 6px; + background: var(--r2-amber-soft); + color: var(--r2-amber); + font-size: 12px; + line-height: 1.6; +} +.sentiment-feedback-strip { + display: grid; + grid-template-columns: 1fr 1fr; + border-top: 1px solid var(--r2-line-soft); +} +.sentiment-feedback-strip > span { + min-width: 0; + display: grid; + grid-template-columns: auto 1fr; + gap: 2px 8px; + padding: 8px 12px; + color: var(--r2-faint); + font-size: 10.5px; +} +.sentiment-feedback-strip > span + span { border-left: 1px solid var(--r2-line-soft); } +.sentiment-feedback-strip strong { color: var(--r2-ink); font-size: 11.5px; text-align: right; } +.sentiment-feedback-strip small { grid-column: 1 / -1; overflow: hidden; color: var(--r2-sub); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } + +.redesigned-sentiment-view .sentiment-component-list { padding: 8px 16px 14px; } +.redesigned-sentiment-view .sentiment-component-item { padding: 6px 0 4px; border: 0; } +.redesigned-sentiment-view .sentiment-component-item > div:first-child { + display: grid; + grid-template-columns: 88px minmax(0, 1fr) 68px; + align-items: center; + gap: 10px; + min-height: 20px; +} +.redesigned-sentiment-view .sentiment-component-item strong { color: var(--r2-sub); font-size: 12px; font-weight: 400; } +.redesigned-sentiment-view .sentiment-component-item span { display: none; } +.redesigned-sentiment-view .sentiment-component-item b { + grid-column: 3; + grid-row: 1; + color: var(--r2-ink); + font-size: 12px; + font-weight: 700; + text-align: right; +} +.redesigned-sentiment-view .sentiment-component-item b em { + color: var(--r2-faint); + font-size: 10px; + font-style: normal; + font-weight: 400; +} +.redesigned-sentiment-view .sentiment-component-track { + grid-column: 2; + grid-row: 1; + height: 9px; + margin: 0; + overflow: hidden; + border-radius: 5px; + background: #f0f2f5; +} +.redesigned-sentiment-view .sentiment-component-track i, +.redesigned-sentiment-view .sentiment-component-item:nth-child(n) .sentiment-component-track i { + display: block; + height: 100%; + border-radius: 5px; + background: linear-gradient(90deg, #93b4f5, var(--r2-blue)); +} +.redesigned-sentiment-view .sentiment-component-item small { + display: block; + margin: 0 0 0 98px; + color: var(--r2-faint); + font-size: 10.5px; + line-height: 1.45; +} + +.redesigned-sentiment-view .sentiment-stage-guide { margin: 0 0 12px; } +.redesigned-sentiment-view .sentiment-stage-guide-head { + display: grid; + grid-template-columns: 18% 38% 18% 26%; + min-height: 34px; + align-items: center; + padding: 0 12px; + border-bottom: 1px solid var(--r2-line); + background: #f8fafc; + color: var(--r2-sub); + font-size: 12px; + font-weight: 600; +} +.redesigned-sentiment-view .sentiment-stage-guide-grid { display: block; } +.redesigned-sentiment-view .sentiment-stage-guide-grid article, +.redesigned-sentiment-view .sentiment-stage-guide-grid article:last-child { + min-height: 38px; + display: grid; + grid-template-columns: 18% 38% 18% 26%; + align-items: center; + padding: 0 12px; + border: 0; + border-bottom: 1px solid var(--r2-line-soft); + background: #fff; +} +.redesigned-sentiment-view .sentiment-stage-guide-grid article:last-child { border-bottom: 0; } +.redesigned-sentiment-view .sentiment-stage-guide-grid article:hover { background: #f8faff; } +.redesigned-sentiment-view .sentiment-stage-guide-grid article.current { background: var(--r2-up-soft); box-shadow: none; } +.redesigned-sentiment-view .sentiment-stage-guide-grid article strong, +.redesigned-sentiment-view .sentiment-stage-guide-grid article span, +.redesigned-sentiment-view .sentiment-stage-guide-grid article small { + display: block; + min-width: 0; + padding: 0 12px 0 0; + overflow: visible; + color: var(--r2-ink); + font-size: 12.5px; + line-height: 1.45; + text-overflow: clip; + white-space: normal; +} +.redesigned-sentiment-view .sentiment-stage-guide-grid article strong { font-weight: 700; } +.redesigned-sentiment-view .sentiment-stage-guide-grid article .stage-range { color: var(--r2-ink); text-align: right; font-variant-numeric: tabular-nums; } +.redesigned-sentiment-view .sentiment-stage-guide-grid article.current strong { color: var(--r2-up); } + +.redesigned-sentiment-view .sentiment-detail-toolbar { + min-height: 44px; + margin: 0; + padding: 9px 14px; + border: 1px solid var(--r2-line); + border-bottom: 0; + border-radius: var(--r2-radius) var(--r2-radius) 0 0; + background: #fff; +} +.redesigned-sentiment-view .sentiment-detail-toolbar h2 { font-size: 14px; font-weight: 700; } +.redesigned-sentiment-view .sentiment-history-frame { + max-height: none; + overflow: auto; + border: 1px solid var(--r2-line); + border-radius: 0 0 var(--r2-radius) var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} +.redesigned-sentiment-view .sentiment-history-table { font-size: 12.5px; } +.redesigned-sentiment-view .sentiment-history-table th, +.redesigned-sentiment-view .sentiment-history-table td { height: auto; padding: 8px 10px; border-bottom: 1px solid var(--r2-line-soft); } +.redesigned-sentiment-view .sentiment-history-table thead th { background: #f8fafc; color: var(--r2-sub); font-size: 12px; font-weight: 600; } +.redesigned-sentiment-view .sentiment-history-groups th { background: #f3f5f7; } + +@media (max-width: 1180px) { + .market-tape .market-item:nth-child(n + 3) { display: none; } + .redesigned-emotion-grid { grid-template-columns: minmax(0, 1fr) 320px; } +} + +@media (max-width: 1023px) and (min-width: 721px) { + body { grid-template-columns: 64px minmax(0, 1fr); } + .module-nav { width: 64px; padding-inline: 7px; } + .sidebar-brand { justify-content: center; margin-inline: -7px; padding-inline: 0; } + .sidebar-brand strong, + .module-nav .nav-group-label, + .module-nav .module-tab span, + .sidebar-collapse-button span { display: none; } + .module-nav .module-tab, + .sidebar-collapse-button { justify-content: center; padding-inline: 0; } + .redesigned-emotion-grid { grid-template-columns: 1fr; } + .sentiment-analysis-rail { display: grid !important; grid-template-columns: 1fr 1fr; } +} + +@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) { + body, + body.sidebar-collapsed { + display: block; + padding-bottom: 58px; + } + .app-header { + position: sticky; + width: 100%; + height: auto; + min-height: 50px; + padding: 6px 8px; + } + .market-tape { display: none; } + .header-actions { width: 100%; } + .header-command-group { position: absolute; } + .module-nav, + body.sidebar-collapsed .module-nav { + inset: auto 0 0; + width: 100%; + height: 58px; + min-height: 58px; + max-height: 58px; + display: flex; + flex-direction: row; + justify-content: space-around; + padding: 0 max(4px, env(safe-area-inset-right)) env(safe-area-inset-bottom) max(4px, env(safe-area-inset-left)); + overflow: hidden; + border: 0; + border-top: 1px solid var(--r2-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: 54px; + display: flex; + flex: 1; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 3px; + padding: 3px 2px; + font-size: 10px; + } + .module-nav .module-tab.mobile-primary-tab span { display: inline; } + .app-main { width: 100%; margin: 0; padding: 0 8px 16px; overflow: visible; } + .overview-strip { margin-inline: -8px; padding-inline: 8px; overflow-x: auto; } + .overview-strip .metric:nth-of-type(n + 4), + .overview-strip .metric-wide { display: none; } + .overview-toggle { display: none; } + .redesigned-page-head { align-items: flex-start; flex-wrap: wrap; margin-top: 12px; } + .redesigned-page-head .section-title-group { width: 100%; flex-wrap: wrap; } + .redesigned-page-head .toolbar-controls { width: 100%; margin-left: 0; justify-content: space-between; } + .redesigned-emotion-grid { grid-template-columns: 1fr; } + .redesigned-sentiment-view .sentiment-chart-shell { height: 270px; padding-inline: 8px; } + .redesigned-sentiment-view .sentiment-chart-shell canvas { height: 250px; } + .sentiment-phase-block { flex-direction: column; } + .sentiment-analysis-rail { display: flex !important; } + .redesigned-sentiment-view .sentiment-stage-guide { overflow-x: auto; } + .redesigned-sentiment-view .sentiment-stage-guide-head, + .redesigned-sentiment-view .sentiment-stage-guide-grid { min-width: 680px; } + .status-bar { display: none; } +} + +@media (prefers-reduced-motion: reduce) { + .redesigned-sentiment-view * { scroll-behavior: auto; } +} + +/* Stage 4: direct visual transfer of pool.html. */ +.redesigned-pool-view { + overflow: visible; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.pool-page-head .section-subtitle { + color: var(--r2-faint); + font-size: 12px; + font-weight: 400; +} + +.pool-filter-segments .segment span { + margin-left: 2px; + color: var(--r2-faint); + font-size: 11px; + font-weight: 400; +} +.pool-filter-segments .segment.active span { color: var(--r2-blue); } + +.pool-search-field { + position: relative; + height: 30px; + display: flex; + align-items: center; + gap: 5px; + padding: 0 9px; + border: 1px solid var(--r2-line); + border-radius: 7px; + background: #fff; +} +.pool-search-field .lucide { + width: 14px; + height: 14px; + flex: 0 0 14px; + color: var(--r2-faint); + stroke-width: 1.8; +} +.pool-search-field input { + width: 174px; + height: 28px; + padding: 0; + border: 0; + border-radius: 0; + outline: 0; + background: transparent; + color: var(--r2-ink); + font-size: 12px; +} +.pool-search-field:focus-within { border-color: var(--r2-blue-line); } + +#limitPool .redesigned-pool-grid { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 12px; + align-items: start; +} + +.redesigned-pool-view .pool-table-card { + width: 100%; + min-width: 0; + max-height: calc(100vh - 230px); + overflow: auto; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} + +.redesigned-pool-view .data-table { + border-collapse: collapse; + color: var(--r2-ink); + font-size: 12.5px; + white-space: nowrap; +} +.redesigned-pool-view .data-table th, +.redesigned-pool-view .data-table td { + height: auto; + padding: 9px 12px; + border: 0; + border-bottom: 1px solid var(--r2-line-soft); + background: #fff; + vertical-align: middle; +} +.redesigned-pool-view .data-table th { + height: 34px; + position: sticky; + top: 0; + z-index: 2; + padding-block: 8px; + background: #f8fafc; + color: var(--r2-sub); + font-size: 12px; + font-weight: 600; +} +.redesigned-pool-view .data-table th[data-sort]:hover { + background: #f8fafc; + color: var(--r2-blue); +} +.redesigned-pool-view .data-table tbody tr:hover td { background: #f8faff; } +.redesigned-pool-view .data-table .row-number { + width: 38px; + color: var(--r2-faint); + text-align: right; +} +.redesigned-pool-view .data-table .number { text-align: right; font-variant-numeric: tabular-nums; } +.redesigned-pool-view .data-table .muted { color: var(--r2-faint); } +.redesigned-pool-view .data-table .up { color: var(--r2-up); } + +.pool-stock-cell { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 128px; +} +.pool-stock-cell .stock-name { color: var(--r2-ink); font-size: 13px; font-weight: 700; } +.pool-stock-cell .stock-code { color: var(--r2-faint); font-size: 11px; font-weight: 400; } + +.pool-streak-tag, +.pool-state-tag { + display: inline-block; + padding: 1.5px 7px; + border: 1px solid transparent; + border-radius: 5px; + font-size: 11px; + line-height: 1.6; +} +.pool-streak-tag { background: var(--r2-up-soft); color: var(--r2-up); } +.pool-state-tag.one-word { background: #fff3d9; color: #ad6800; } +.pool-state-tag.broken { background: #f3f4f6; color: #6b7280; } + +.pool-insight-rail { + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; + border: 0; + background: transparent; +} +.pool-insight-rail .rail-section { + margin: 0; + padding: 0; + overflow: hidden; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} +.pool-insight-rail .rail-section + .rail-section { border-top: 1px solid var(--r2-line); } +.pool-insight-rail .rail-heading { + min-height: 42px; + margin: 0; + padding: 11px 14px; + border-bottom: 1px solid var(--r2-line-soft); +} +.pool-insight-rail .rail-heading h3 { color: var(--r2-ink); font-size: 14px; font-weight: 700; } +.pool-insight-rail .rail-heading > span { + margin-left: auto; + padding: 2px 7px; + border-radius: 5px; + background: #f3f4f6; + color: var(--r2-sub); + font-size: 11px; + font-weight: 400; +} +.pool-insight-rail .text-button { + margin-left: auto; + padding: 3px 0; + color: var(--r2-blue); + font-size: 12px; +} + +.pool-side-list { + display: block; + padding: 6px 14px 10px; +} +.pool-side-group { + padding: 7px 0; + border-bottom: 1px dashed var(--r2-line-soft); +} +.pool-side-group:last-child { border-bottom: 0; } +.pool-side-group > div { display: flex; align-items: center; margin-bottom: 4px; font-size: 12px; } +.pool-side-group > div strong { color: var(--r2-up); font-weight: 700; } +.pool-side-group > div small { margin-left: auto; color: var(--r2-faint); font-size: 11px; } +.pool-side-group p { + margin: 0; + overflow: hidden; + color: #4b5563; + font-size: 12px; + line-height: 1.8; + text-overflow: ellipsis; + white-space: nowrap; +} +.pool-side-group p em { color: var(--r2-faint); font-style: normal; } + +.pool-hot-list { + display: block; + padding: 6px 14px 10px; +} +.pool-hot-row { + display: flex; + align-items: center; + gap: 8px; + padding: 5.5px 0; + border-bottom: 1px dashed var(--r2-line-soft); + font-size: 12.5px; +} +.pool-hot-row:last-child { border-bottom: 0; } +.pool-hot-row strong { + min-width: 0; + flex: 1; + overflow: hidden; + color: var(--r2-ink); + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} +.pool-hot-row span { color: var(--r2-up); font-weight: 700; font-variant-numeric: tabular-nums; } + +@media (max-width: 1100px) { + #limitPool .redesigned-pool-grid { grid-template-columns: minmax(0, 1fr) 280px; } + .pool-search-field input { width: 135px; } +} + +@media (max-width: 860px) { + .pool-page-head { align-items: flex-start; flex-wrap: wrap; } + .pool-page-head .section-title-group { width: 100%; } + .pool-page-head .toolbar-controls { width: 100%; margin-left: 0; } + #limitPool .redesigned-pool-grid { grid-template-columns: minmax(0, 1fr); } + .redesigned-pool-view .pool-table-card { max-height: 560px; } + .pool-insight-rail { display: grid; grid-template-columns: 1fr 1fr; } +} + +@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) { + .pool-page-head .toolbar-controls { flex-wrap: wrap; } + .pool-filter-segments { width: 100%; } + .pool-filter-segments .segment { min-width: 0; flex: 1; padding-inline: 6px; } + .pool-search-field { min-width: 0; flex: 1; } + .pool-search-field input { width: 100%; } + #limitPool .redesigned-pool-grid { + width: 100%; + min-width: 0; + display: flex; + flex-direction: column; + } + .redesigned-pool-view .pool-table-card { max-height: 520px; } + .pool-insight-rail { width: 100%; min-width: 0; display: flex; } +} + +/* Stage 5: direct visual transfer of broken.html. */ +.redesigned-broken-view { + overflow: visible; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.broken-page-head .section-subtitle b { + color: var(--r2-faint); + font-weight: 400; +} + +.broken-page-head .toolbar-controls { + display: flex; + align-items: center; + gap: 8px; +} + +.broken-search-field input { width: 180px; } + +.broken-export-button { + min-height: 28px; + height: 28px; + padding: 0 9px; + border: 1px solid var(--r2-line); + border-radius: 7px; + background: #fff; + color: #374151; + font-size: 12px; + font-weight: 500; + box-shadow: none; +} +.broken-export-button:hover { border-color: var(--r2-blue-line); color: var(--r2-blue); } + +.redesigned-broken-view .broken-table-card { + width: 100%; + min-width: 0; + max-height: calc(100vh - 172px); + overflow: auto; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} + +.redesigned-broken-view .data-table { + width: 100%; + border-collapse: collapse; + color: var(--r2-ink); + font-size: 12.5px; + white-space: nowrap; +} + +.redesigned-broken-view .data-table th, +.redesigned-broken-view .data-table td { + height: auto; + padding: 9px 12px; + border: 0; + border-bottom: 1px solid var(--r2-line-soft); + background: #fff; + vertical-align: middle; +} + +.redesigned-broken-view .data-table th { + height: 34px; + position: sticky; + top: 0; + z-index: 2; + padding-block: 8px; + background: #f8fafc; + color: var(--r2-sub); + font-size: 12px; + font-weight: 600; +} + +.redesigned-broken-view .data-table th[data-broken-sort] { + cursor: pointer; + user-select: none; +} +.redesigned-broken-view .data-table th[data-broken-sort]:hover { color: var(--r2-blue); } +.redesigned-broken-view .data-table th[data-broken-sort]::after { + content: "\2195"; + margin-left: 3px; + color: var(--r2-faint); + font-size: 9px; +} +.redesigned-broken-view .data-table th[data-broken-sort].sort-asc::after { content: "\2191"; color: var(--r2-blue); } +.redesigned-broken-view .data-table th[data-broken-sort].sort-desc::after { content: "\2193"; color: var(--r2-blue); } + +.redesigned-broken-view .data-table tbody tr:hover td { background: #f8faff; } +.redesigned-broken-view .data-table .row-number { + width: 38px; + color: var(--r2-faint); + text-align: right; +} +.redesigned-broken-view .data-table .number { text-align: right; font-variant-numeric: tabular-nums; } +.redesigned-broken-view .data-table .muted { color: var(--r2-faint); } +.redesigned-broken-view .data-table .up { color: var(--r2-up); } +.redesigned-broken-view .data-table .down { color: var(--r2-down); } +.redesigned-broken-view .broken-limit-gap { color: var(--r2-amber); font-weight: 700; } + +.broken-repeat-tag { + display: inline-block; + padding: 1.5px 7px; + border-radius: 5px; + background: var(--r2-amber-soft); + color: var(--r2-amber); + font-size: 11px; + line-height: 1.6; +} + +.redesigned-broken-view .empty-state { + padding: 36px; + border: 0; + color: var(--r2-faint); + text-align: center; +} + +@media (max-width: 720px), (max-width: 1023px) and (max-height: 600px) { + .broken-page-head .toolbar-controls { flex-wrap: nowrap; } + .broken-search-field { min-width: 0; flex: 1; } + .broken-search-field input { width: 100%; } + .redesigned-broken-view .broken-table-card { max-height: 520px; } +} + +/* Stage 6: direct visual transfer of limit.html. */ +.redesigned-down-view { + overflow: visible; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.down-page-head .section-subtitle b { + color: var(--r2-faint); + font-weight: 400; +} + +.down-page-head .toolbar-controls { + display: flex; + align-items: center; + gap: 8px; +} + +.down-sector-cluster { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 3px 9px; + border-radius: 5px; + background: var(--r2-down-soft); + color: var(--r2-down); + font-size: 11px; + font-weight: 500; + white-space: nowrap; +} +.down-sector-cluster[hidden] { display: none; } + +.down-search-field input { width: 180px; } + +.down-export-button { + min-height: 28px; + height: 28px; + padding: 0 9px; + border: 1px solid var(--r2-line); + border-radius: 7px; + background: #fff; + color: #374151; + font-size: 12px; + font-weight: 500; + box-shadow: none; +} +.down-export-button:hover { border-color: var(--r2-blue-line); color: var(--r2-blue); } + +.redesigned-down-view .down-table-card { + width: 100%; + min-width: 0; + max-height: none; + overflow: auto; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} + +.redesigned-down-view .data-table { + width: 100%; + border-collapse: collapse; + color: var(--r2-ink); + font-size: 12.5px; + white-space: nowrap; +} + +.redesigned-down-view .data-table th, +.redesigned-down-view .data-table td { + height: auto; + padding: 9px 12px; + border: 0; + border-bottom: 1px solid var(--r2-line-soft); + background: #fff; + vertical-align: middle; +} + +.redesigned-down-view .data-table th { + height: 34px; + position: sticky; + top: 0; + z-index: 2; + padding-block: 8px; + background: #f8fafc; + color: var(--r2-sub); + font-size: 12px; + font-weight: 600; +} + +.redesigned-down-view .data-table th[data-down-sort] { + cursor: pointer; + user-select: none; +} +.redesigned-down-view .data-table th[data-down-sort]:hover { color: var(--r2-blue); } +.redesigned-down-view .data-table th[data-down-sort]::after { + content: "\2195"; + margin-left: 3px; + color: var(--r2-faint); + font-size: 9px; +} +.redesigned-down-view .data-table th[data-down-sort].sort-asc::after { content: "\2191"; color: var(--r2-blue); } +.redesigned-down-view .data-table th[data-down-sort].sort-desc::after { content: "\2193"; color: var(--r2-blue); } + +.redesigned-down-view .data-table tbody tr:hover td { background: #f8faff; } +.redesigned-down-view .data-table .row-number { + width: 38px; + color: var(--r2-faint); + text-align: right; +} +.redesigned-down-view .data-table .number { text-align: right; font-variant-numeric: tabular-nums; } +.redesigned-down-view .data-table .down { color: var(--r2-down); } +.redesigned-down-view .empty-state { + padding: 36px; + border: 0; + color: var(--r2-faint); + text-align: center; +} + +@media (max-width: 900px) { + .down-page-head { align-items: flex-start; flex-wrap: wrap; } + .down-page-head .section-title-group { width: 100%; } + .down-page-head .toolbar-controls { width: 100%; margin-left: 0; } + .down-search-field { min-width: 0; flex: 1; } + .down-search-field input { width: 100%; } +} + +@media (max-width: 520px) { + .down-page-head .toolbar-controls { flex-wrap: wrap; } + .down-sector-cluster { order: -1; } + .down-search-field { flex-basis: calc(100% - 76px); } +} + +/* Stage 7: direct visual transfer of yesterday.html. */ +.redesigned-yesterday-view { + overflow: visible; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.yesterday-page-head .section-subtitle b { + color: var(--r2-faint); + font-weight: 400; +} + +.yesterday-page-head .toolbar-controls { + display: flex; + align-items: center; + gap: 8px; +} + +.yesterday-search-field input { width: 180px; } + +.yesterday-export-button { + min-height: 28px; + height: 28px; + padding: 0 9px; + border: 1px solid var(--r2-line); + border-radius: 7px; + background: #fff; + color: #374151; + font-size: 12px; + font-weight: 500; + box-shadow: none; +} +.yesterday-export-button:hover { border-color: var(--r2-blue-line); color: var(--r2-blue); } + +.yesterday-table-card { + min-width: 0; + overflow: hidden; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} + +.yesterday-result-summary { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 1px; + border-bottom: 1px solid var(--r2-line-soft); + background: var(--r2-line-soft); +} + +.yesterday-summary-cell { + min-width: 0; + display: block; + padding: 12px 16px; + border: 0; + border-radius: 0; + background: #fff; + color: var(--r2-ink); + text-align: left; + box-shadow: none; + transition: background 150ms ease; +} +.yesterday-summary-cell:hover { background: #f8faff; } +.yesterday-summary-cell.active { + background: var(--r2-blue-soft); + box-shadow: inset 0 -2px 0 var(--r2-blue); +} +.yesterday-summary-cell .summary-label { + min-height: 18px; + display: flex; + align-items: center; + gap: 6px; + color: var(--r2-sub); + font-size: 12px; +} +.yesterday-summary-cell > strong { + display: block; + margin-top: 2px; + color: var(--r2-ink); + font-size: 22px; + font-weight: 800; + font-variant-numeric: tabular-nums; +} +.yesterday-summary-cell > strong.up { color: var(--r2-up); } +.yesterday-summary-cell > strong.down { color: var(--r2-down); } +.yesterday-summary-cell > strong b { font: inherit; } +.yesterday-summary-cell > strong small { color: var(--r2-faint); font-size: 11px; font-weight: 400; } +.yesterday-summary-cell > em { + display: block; + margin-top: 2px; + color: var(--r2-faint); + font-size: 11px; + font-style: normal; +} + +.yesterday-outcome-tag, +.yesterday-height-tag { + display: inline-block; + padding: 1.5px 7px; + border-radius: 5px; + font-size: 11px; + font-style: normal; + font-weight: 500; + line-height: 1.6; + white-space: nowrap; +} +.yesterday-outcome-tag.advance, +.yesterday-outcome-tag.positive, +.yesterday-height-tag { background: var(--r2-up-soft); color: var(--r2-up); } +.yesterday-outcome-tag.fail { border: 1px solid var(--r2-line); background: #f3f4f6; color: #4b5563; } +.yesterday-outcome-tag.broken { background: var(--r2-amber-soft); color: var(--r2-amber); } +.yesterday-outcome-tag.down { background: var(--r2-down-soft); color: var(--r2-down); } + +.redesigned-yesterday-view .yesterday-table-scroll { + min-height: 0; + max-height: calc(100vh - 330px); + overflow: auto; + border: 0; +} + +.redesigned-yesterday-view .data-table { + width: 100%; + border-collapse: collapse; + color: var(--r2-ink); + font-size: 12.5px; + white-space: nowrap; +} +.redesigned-yesterday-view .data-table th, +.redesigned-yesterday-view .data-table td { + height: auto; + padding: 9px 12px; + border: 0; + border-bottom: 1px solid var(--r2-line-soft); + background: #fff; + vertical-align: middle; +} +.redesigned-yesterday-view .data-table th { + height: 34px; + position: sticky; + top: 0; + z-index: 2; + padding-block: 8px; + background: #f8fafc; + color: var(--r2-sub); + font-size: 12px; + font-weight: 600; +} +.redesigned-yesterday-view .data-table th[data-yesterday-sort] { cursor: pointer; user-select: none; } +.redesigned-yesterday-view .data-table th[data-yesterday-sort]:hover { color: var(--r2-blue); } +.redesigned-yesterday-view .data-table th[data-yesterday-sort]::after { + content: "\2195"; + margin-left: 3px; + color: var(--r2-faint); + font-size: 9px; +} +.redesigned-yesterday-view .data-table th[data-yesterday-sort].sort-asc::after { content: "\2191"; color: var(--r2-blue); } +.redesigned-yesterday-view .data-table th[data-yesterday-sort].sort-desc::after { content: "\2193"; color: var(--r2-blue); } +.redesigned-yesterday-view .data-table tbody tr:hover td { background: #f8faff; } +.redesigned-yesterday-view .data-table .row-number { + width: 38px; + color: var(--r2-faint); + text-align: right; +} +.redesigned-yesterday-view .data-table .number { text-align: right; font-variant-numeric: tabular-nums; } +.redesigned-yesterday-view .data-table .up { color: var(--r2-up); } +.redesigned-yesterday-view .data-table .down { color: var(--r2-down); } +.redesigned-yesterday-view .empty-state { + padding: 36px; + border: 0; + color: var(--r2-faint); + text-align: center; +} + +@media (max-width: 820px) { + .yesterday-page-head { align-items: flex-start; flex-wrap: wrap; } + .yesterday-page-head .section-title-group { width: 100%; } + .yesterday-page-head .toolbar-controls { width: 100%; margin-left: 0; } + .yesterday-search-field { min-width: 0; flex: 1; } + .yesterday-search-field input { width: 100%; } + .yesterday-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .yesterday-summary-cell:last-child:nth-child(odd) { grid-column: 1 / -1; } + .redesigned-yesterday-view .yesterday-table-scroll { max-height: none; } +} + +@media (max-width: 420px) { + .yesterday-summary-cell { padding: 10px 12px; } + .yesterday-summary-cell > strong { font-size: 19px; } +} + +/* Stage 8: limit-up performance, transferred from ../界面优化/perf.html. */ +.redesigned-performance-view { + overflow: visible; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--r2-ink); + box-shadow: none; +} + +.performance-page-head { + margin-bottom: 12px; +} + +.performance-page-head .section-subtitle b { + font-weight: 400; +} + +.redesigned-performance-view .performance-cards { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + margin: 0 0 12px; + padding: 0; + border: 0; +} + +.performance-stage-card { + min-width: 0; + padding: 14px 16px; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} + +.performance-stage-label { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--r2-sub); + font-size: 12px; +} + +.performance-stage-label > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.performance-status-tag { + flex: 0 0 auto; + display: inline-block; + padding: 1.5px 7px; + border: 1px solid transparent; + border-radius: 5px; + font-size: 11px; + font-style: normal; + font-weight: 500; + line-height: 1.6; +} + +.performance-status-tag.is-neutral { + border-color: var(--r2-line); + background: #f3f4f6; + color: #4b5563; +} + +.performance-status-tag.is-warning { + background: var(--r2-amber-soft); + color: var(--r2-amber); +} + +.performance-status-tag.is-active { + background: var(--r2-up-soft); + color: var(--r2-up); +} + +.performance-stage-rate { + display: block; + margin-top: 6px; + color: var(--r2-up); + font-size: 26px; + font-weight: 800; + font-variant-numeric: tabular-nums; + letter-spacing: 0; +} + +.performance-stage-rate.is-neutral { color: var(--r2-faint); } +.performance-stage-rate.is-warning { color: #d97706; } +.performance-stage-rate.is-active { color: var(--r2-up); } + +.performance-stage-count { + display: block; + margin-top: 4px; + color: var(--r2-faint); + font-size: 11.5px; +} + +.performance-stage-track { + height: 6px; + margin-top: 10px; + overflow: hidden; + border-radius: 3px; + background: #f0f2f5; +} + +.performance-stage-track i { + display: block; + height: 100%; + border-radius: 3px; + background: var(--r2-up); + transition: width 620ms ease; +} + +.performance-stage-track i.is-neutral { background: var(--r2-faint); } +.performance-stage-track i.is-warning { background: #d97706; } +.performance-stage-track i.is-active { background: var(--r2-up); } + +.performance-empty-state { + grid-column: 1 / -1; + padding: 34px; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + color: var(--r2-faint); + text-align: center; +} + +.performance-insight-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 12px; + align-items: start; +} + +.performance-panel-card { + min-width: 0; + overflow: hidden; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} + +.redesigned-performance-view .market-breadth-panel { + margin: 0; + padding: 0; + background: #fff; +} + +.performance-panel-head { + min-height: 43px; + display: flex; + align-items: center; + gap: 8px; + padding: 11px 14px; + border-bottom: 1px solid var(--r2-line-soft); +} + +.performance-panel-head h3 { + margin: 0; + color: var(--r2-ink); + font-size: 14px; + font-weight: 700; +} + +.performance-date-tag { + margin-left: auto; + padding: 2px 7px; + border-radius: 5px; + background: #f3f4f6; + color: var(--r2-sub); + font-size: 11px; + white-space: nowrap; +} + +.performance-width-box { + padding: 14px 16px; +} + +.performance-width-summary { + display: flex; + justify-content: space-between; + gap: 16px; + color: var(--r2-ink); + font-size: 12.5px; +} + +.performance-width-summary b { + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.performance-width-bar { + height: 22px; + display: flex; + margin-top: 8px; + overflow: hidden; + border-radius: 6px; + background: #f0f2f5; +} + +.performance-width-bar i { + display: block; + width: 0; + transition: width 760ms ease; +} + +.performance-width-bar .breadth-up { background: var(--r2-up); } +.performance-width-bar .breadth-flat { background: #aab3bc; } +.performance-width-bar .breadth-down { background: var(--r2-down); } + +.performance-width-legend { + display: flex; + align-items: center; + gap: 18px; + margin-top: 8px; + color: var(--r2-sub); + font-size: 11.5px; +} + +.performance-width-legend > span { + white-space: nowrap; +} + +.performance-width-legend b { + font-weight: 400; +} + +.performance-width-legend i { + width: 10px; + height: 10px; + display: inline-block; + margin-right: 4px; + border-radius: 2px; + vertical-align: -1px; +} + +.performance-width-legend .up-swatch { background: var(--r2-up); } +.performance-width-legend .flat-swatch { background: #aab3bc; } +.performance-width-legend .down-swatch { background: var(--r2-down); } + +.performance-width-warning { + margin-left: auto; + color: var(--r2-amber); +} + +.performance-conclusion { + padding: 14px 16px; + color: #374151; + font-size: 12.5px; + line-height: 2; +} + +.performance-conclusion b { + color: var(--r2-ink); + font-weight: 700; +} + +.performance-conclusion b.up, +.performance-conclusion b.is-active { color: var(--r2-up); } +.performance-conclusion b.is-warning { color: var(--r2-amber); } +.performance-conclusion b.is-neutral { color: var(--r2-faint); } + +@media (max-width: 1180px) { + .redesigned-performance-view .performance-cards { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .performance-insight-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 720px) { + .redesigned-performance-view .performance-cards { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .performance-stage-card { + min-height: 0; + padding: 12px; + } + + .performance-width-summary, + .performance-width-legend { + flex-wrap: wrap; + } + + .performance-width-warning { + width: 100%; + margin-left: 0; + } +} + +@media (max-width: 420px) { + .redesigned-performance-view .performance-cards { + grid-template-columns: minmax(0, 1fr); + } +} + +/* Stage 9: market ladder, transferred from ../界面优化/ladder.html. */ +.redesigned-ladder-view { + overflow: visible; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.ladder-page-head { margin-bottom: 12px; } +.ladder-page-head .section-subtitle b { font-weight: 400; } + +.ladder-head-actions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.ladder-sort-segment { + display: inline-flex; + gap: 2px; + padding: 2px; + border-radius: 8px; + background: #f3f4f6; +} + +.ladder-sort-segment button { + padding: 4px 12px; + border-radius: 6px; + color: var(--r2-sub); + font-size: 12px; +} + +.ladder-sort-segment button.active { + background: #fff; + color: var(--r2-ink); + font-weight: 600; + box-shadow: 0 1px 2px rgba(0, 0, 0, .08); +} + +.market-ladder-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 12px; + align-items: start; +} + +.market-ladder-board { + min-width: 0; + overflow: hidden; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} + +.market-ladder-tier { + display: flex; + min-width: 0; + border-bottom: 1px solid var(--r2-line-soft); +} + +.market-ladder-tier:last-child { border-bottom: 0; } + +.market-ladder-label { + width: 118px; + flex: 0 0 118px; + padding: 14px 0 14px 16px; + border-right: 1px solid var(--r2-line-soft); + background: linear-gradient(90deg, color-mix(in srgb, var(--tier-color) 8%, #fff), #fff); +} + +.market-ladder-tier.is-gap .market-ladder-label { + background: repeating-linear-gradient(45deg, #fafafa, #fafafa 8px, #f3f4f6 8px, #f3f4f6 16px); +} + +.market-ladder-level { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--tier-color); + font-size: 15px; + font-weight: 800; +} + +.market-ladder-dot { + width: 9px; + height: 9px; + display: inline-block; + border-radius: 3px; + background: var(--tier-color); +} + +.market-ladder-count { + margin-top: 3px; + color: var(--r2-faint); + font-size: 11px; +} + +.market-ladder-rate { + margin-top: 6px; + color: var(--r2-sub); + font-size: 10.5px; +} + +.market-ladder-rate b { color: var(--r2-ink); font-weight: 700; } + +.market-ladder-stocks { + min-width: 0; + flex: 1; + display: grid; + grid-template-columns: repeat(auto-fill, 190px); + justify-content: start; + align-content: flex-start; + gap: 8px; + padding: 12px 14px; +} + +.market-ladder-stock { + min-width: 0; + width: 190px; + display: block; + padding: 7px 11px; + border: 1px solid var(--r2-line); + border-radius: 8px; + background: #fff; + color: var(--r2-ink); + text-align: left; + transition: border-color 150ms ease, box-shadow 150ms ease, transform 150ms ease; +} + +.market-ladder-stock:hover, +.market-ladder-stock:focus-visible { + border-color: var(--r2-blue-line); + box-shadow: 0 3px 10px rgba(16, 24, 40, .1); + outline: 0; + transform: translateY(-1px); +} + +.market-ladder-stock-first, +.market-ladder-stock-second { + min-width: 0; + display: flex; + align-items: center; + gap: 6px; +} + +.market-ladder-stock-first strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 800; +} + +.market-ladder-stock-first .stock-code { + flex: 0 0 auto; + color: var(--r2-faint); + font-size: 10.5px; +} + +.market-ladder-tags { + min-width: 0; + display: flex; + gap: 3px; + margin-left: auto; +} + +.market-ladder-tag { + display: inline-block; + padding: 1px 5px; + border-radius: 4px; + font-size: 10px; + font-style: normal; + line-height: 1.5; + white-space: nowrap; +} + +.market-ladder-tag.one-price { background: var(--r2-up-soft); color: #c22e2e; font-weight: 700; } +.market-ladder-tag.broken { background: var(--r2-amber-soft); color: var(--r2-amber); } + +.market-ladder-stock-second { + margin-top: 4px; + color: var(--r2-sub); + font-size: 11px; +} + +.market-ladder-stock-second b { + max-width: 92px; + overflow: hidden; + padding: 0 5px; + border-radius: 4px; + background: var(--r2-blue-soft); + color: var(--r2-blue); + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + font-weight: 500; +} + +.market-ladder-stock-second small { + color: var(--r2-sub); + font-size: 10.5px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.market-ladder-stock-second small:last-child { margin-left: auto; color: var(--r2-faint); } + +.market-ladder-gap-note { + align-self: center; + padding: 8px 14px; + border: 1.5px dashed var(--r2-line); + border-radius: 8px; + color: var(--r2-faint); + font-size: 12px; +} + +.market-ladder-more { + align-self: center; + justify-self: start; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 8px 16px; + border: 1px dashed var(--r2-line); + border-radius: 8px; + color: var(--r2-sub); + font-size: 12px; + white-space: nowrap; +} + +.market-ladder-more:hover, +.market-ladder-more:focus-visible { + border-color: var(--r2-blue-line); + color: var(--r2-blue); + outline: 0; +} + +.market-ladder-insights { min-width: 0; display: flex; flex-direction: column; gap: 12px; } + +.market-ladder-insight-card { + min-width: 0; + overflow: hidden; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} + +.market-ladder-insight-card > header { + min-height: 42px; + display: flex; + align-items: center; + padding: 0 14px; + border-bottom: 1px solid var(--r2-line-soft); +} + +.market-ladder-insight-card > header h3 { margin: 0; font-size: 13.5px; font-weight: 700; } +.market-ladder-insight-card > header span { margin-left: auto; padding: 2px 7px; border-radius: 5px; background: #f3f4f6; color: var(--r2-sub); font-size: 11px; } + +.market-ladder-apex { padding: 14px 16px 8px; } +.market-ladder-apex > div { display: flex; align-items: baseline; gap: 10px; } +.market-ladder-apex strong { color: var(--r2-up); font-size: 26px; font-weight: 800; } +.market-ladder-apex em { padding: 2px 7px; border-radius: 5px; background: var(--r2-amber-soft); color: var(--r2-amber); font-size: 11.5px; font-style: normal; } +.market-ladder-apex p { margin: 8px 0 0; color: var(--r2-ink); font-size: 12.5px; line-height: 1.9; } +.market-ladder-apex p b { font-weight: 700; } +.market-ladder-insight-card > p { margin: 0; padding: 0 14px 12px; color: var(--r2-sub); font-size: 11.5px; line-height: 1.7; } + +.market-ladder-pyramid { padding: 8px 16px 10px; } +.market-ladder-pyramid-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; } +.market-ladder-pyramid-row > span { width: 52px; color: var(--r2-sub); font-size: 12px; text-align: right; } +.market-ladder-pyramid-row > i { flex: 1; height: 14px; overflow: hidden; border-radius: 4px; background: #f3f4f6; } +.market-ladder-pyramid-row > i b { display: block; height: 100%; border-radius: 4px; background: var(--r2-blue); } +.market-ladder-pyramid-row:nth-child(2) > i b { background: var(--r2-up); } +.market-ladder-pyramid-row:nth-child(3) > i b { background: var(--r2-amber); } +.market-ladder-pyramid-row:nth-child(4) > i b { background: var(--r2-down); } +.market-ladder-pyramid-row.is-gap > i { background: repeating-linear-gradient(45deg, #e5e7eb, #e5e7eb 4px, #f3f4f6 4px, #f3f4f6 8px); } +.market-ladder-pyramid-row.is-gap > i b { background: transparent; } +.market-ladder-pyramid-row > strong { width: 44px; color: var(--r2-ink); font-size: 12px; font-variant-numeric: tabular-nums; } +.market-ladder-pyramid-row.is-gap > strong { color: var(--r2-faint); font-weight: 400; } + +.market-ladder-rate-list { padding: 6px 16px 8px; } +.market-ladder-rate-list > div { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: 12px; } +.market-ladder-rate-list > div > span { width: 112px; color: var(--r2-sub); } +.market-ladder-rate-list > div > i { flex: 1; height: 8px; overflow: hidden; border-radius: 4px; background: #f3f4f6; } +.market-ladder-rate-list > div > i b { display: block; height: 100%; border-radius: 4px; background: var(--r2-up); } +.market-ladder-rate-list > div > i b.is-low { background: #f59e0b; } +.market-ladder-rate-list > div > i b.is-zero { background: #d1d5db; } +.market-ladder-rate-list > div > strong { width: 44px; color: var(--r2-up); text-align: right; font-variant-numeric: tabular-nums; } +.market-ladder-rate-list > div > strong.is-low { color: var(--r2-amber); } +.market-ladder-rate-list > div > strong.is-zero { color: var(--r2-faint); } +.market-ladder-source { display: block; padding: 0 16px 12px; color: var(--r2-faint); font-size: 10.5px; } + +@media (max-width: 1020px) { + .market-ladder-workspace { grid-template-columns: minmax(0, 1fr); } + .market-ladder-insights { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); } +} + +@media (max-width: 720px) { + .redesigned-performance-view, + .redesigned-ladder-view { padding: 0; } + .ladder-page-head { align-items: flex-start; flex-wrap: wrap; } + .ladder-page-head .section-title-group { width: 100%; } + .ladder-head-actions { width: 100%; margin-left: 0; justify-content: space-between; } + .market-ladder-insights { grid-template-columns: minmax(0, 1fr); } + .market-ladder-tier { display: block; } + .market-ladder-label { width: 100%; padding: 10px 12px; border-right: 0; border-bottom: 1px solid var(--r2-line-soft); } + .market-ladder-level { font-size: 14px; } + .market-ladder-rate { display: inline-block; margin: 0 0 0 8px; } + .market-ladder-stocks { padding: 9px; } + .market-ladder-stocks { grid-template-columns: minmax(0, 1fr); } + .market-ladder-stock { width: 100%; min-width: 0; } + .market-ladder-more { margin: 0 0 0 8px; } +} + +/* Stage 10: sector rotation, transferred from rotation.html. */ +.redesigned-rotation-view { + min-width: 0; + padding: 14px 16px 18px; +} + +.rotation-page-head { + min-height: 34px; + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.rotation-page-head h2 { + margin: 0; + color: var(--r2-ink); + font-size: 17px; + font-weight: 800; + letter-spacing: 0; +} + +.rotation-page-head p { + margin: 2px 0 0; + color: var(--r2-faint); + font-size: 12px; + line-height: 1.5; +} + +.rotation-head-actions { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.rotation-order-control { + display: inline-flex; + gap: 2px; + padding: 2px; + border-radius: 8px; + background: #f3f4f6; +} + +.rotation-order-control button { + min-height: 28px; + padding: 4px 12px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--r2-sub); + font-size: 12px; + line-height: 1; +} + +.rotation-order-control button.active { + background: #fff; + color: var(--r2-ink); + font-weight: 600; + box-shadow: 0 1px 2px rgba(0, 0, 0, .08); +} + +.rotation-order-control button:focus-visible, +.rotation-export-button:focus-visible, +.rotation-track-cancel:focus-visible, +.rotation-sector-chip:focus-visible { + outline: 2px solid rgba(37, 99, 235, .28); + outline-offset: 2px; +} + +.rotation-export-button, +.rotation-track-cancel { + min-height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 5px 12px; + border: 1px solid var(--r2-line); + border-radius: 7px; + background: #fff; + color: #374151; + font-size: 12px; + font-weight: 500; +} + +.rotation-export-button:hover, +.rotation-track-cancel:hover { + border-color: var(--r2-blue-line); + color: var(--r2-blue); +} + +.rotation-trajectory-card, +.rotation-detail-card { + min-width: 0; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} + +.rotation-detail-card { margin-top: 12px; overflow: hidden; } + +.rotation-card-head { + min-height: 45px; + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--r2-line-soft); +} + +.rotation-card-head > div { + min-width: 0; + display: flex; + align-items: baseline; + gap: 8px; +} + +.rotation-card-head h3 { + flex: 0 0 auto; + margin: 0; + color: var(--r2-ink); + font-size: 14px; + font-weight: 700; + letter-spacing: 0; +} + +.rotation-card-head > div > span { + min-width: 0; + color: var(--r2-faint); + font-size: 11px; + line-height: 1.5; +} + +.rotation-top-tag { + flex: 0 0 auto; + margin-left: auto; + padding: 2px 7px; + border-radius: 5px; + background: #f3f4f6; + color: var(--r2-sub); + font-size: 11px; + font-weight: 400; + white-space: nowrap; +} + +#rotationView .rotation-legend { + min-height: 36px; + display: flex; + align-items: center; + gap: 14px; + padding: 8px 14px; + border-bottom: 1px solid var(--r2-line-soft); + color: var(--r2-sub); + font-size: 11px; + line-height: 1.5; +} + +#rotationView .rotation-legend > span { + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +#rotationView .rotation-swatch { + width: 14px; + height: 10px; + flex: 0 0 auto; + display: inline-block; + border-radius: 2px; +} + +#rotationView .rotation-swatch.strong { background: #709bf5; } +#rotationView .rotation-swatch.warm { background: #cbdcff; } +#rotationView .rotation-swatch.mild { background: #f0f4fa; border: 1px solid #dfe6f0; } + +#rotationView .rotation-tracker { + min-height: 48px; + display: flex; + align-items: center; + gap: 16px; + padding: 8px 14px; + border-bottom: 1px solid var(--r2-blue-line); + background: var(--r2-blue-soft); +} + +#rotationView .rotation-tracker[hidden] { display: none; } + +#rotationView .rotation-tracker-copy { + min-width: 260px; + display: flex; + align-items: baseline; + gap: 12px; +} + +#rotationView .rotation-tracker-copy strong { + flex: 0 0 auto; + color: var(--r2-blue); + font-size: 13.5px; + font-weight: 800; +} + +#rotationView .rotation-tracker-copy span { + color: #3b62c4; + font-size: 12px; + white-space: nowrap; +} + +#rotationView .rotation-tracker-copy b { font-weight: 700; } + +#rotationView .rotation-tracker-spark { + height: 28px; + flex: 1; + display: grid; + grid-template-columns: repeat(9, 12px); + align-items: end; + gap: 4px; +} + +#rotationView .rotation-tracker-spark > span { + height: 100%; + display: flex; + align-items: end; + justify-content: center; + position: relative; +} + +#rotationView .rotation-tracker-spark i { + width: 12px; + height: var(--spark-height); + min-height: 4px; + display: block; + border-radius: 2px 2px 0 0; + background: #93b4f5; +} + +#rotationView .rotation-tracker-spark small { + position: absolute; + top: -1px; + color: #3b62c4; + font-size: 9px; +} + +#rotationView .rotation-tracker-spark .missing i { + height: 8px; + border: 1px dashed #b9c8e8; + border-bottom: 0; + background: transparent; +} + +#rotationView .rotation-tracker-spark .missing small { display: none; } +.rotation-track-cancel { margin-left: auto; padding: 4px 9px; } + +#rotationView .rotation-history { + width: 100%; + min-height: 0; + display: grid; + grid-template-columns: repeat(9, minmax(0, 1fr)); + gap: 0; + padding: 0; + overflow: visible; +} + +#rotationView .rotation-day { + min-width: 0; + border: 0; + border-right: 1px solid var(--r2-line-soft); + border-radius: 0; + background: #fff; + box-shadow: none; +} + +#rotationView .rotation-day:last-child { border-right: 0; } + +#rotationView .rotation-day > header { + min-height: 46px; + padding: 8px 9px; + border-bottom: 1px solid var(--r2-line-soft); + background: #f8fafc; +} + +#rotationView .rotation-day > header time { + display: block; + color: var(--r2-ink); + font-size: 12.5px; + font-weight: 700; +} + +#rotationView .rotation-day > header span { + display: block; + margin-top: 1px; + color: var(--r2-faint); + font-size: 10.5px; +} + +#rotationView .rotation-day.latest-day > header { background: var(--r2-blue-soft); } +#rotationView .rotation-day.latest-day > header time { color: var(--r2-blue); } + +#rotationView .rotation-day.latest-day > header time::after { + content: "最新"; + margin-left: 5px; + padding: 0 4px; + border-radius: 4px; + background: var(--r2-blue); + color: #fff; + font-size: 9px; + font-weight: 600; + vertical-align: 1px; +} + +#rotationView .rotation-day-sectors { + display: grid; + gap: 4px; + padding: 5px; + background: #fbfcfe; +} + +#rotationView .rotation-sector-chip { + min-width: 0; + min-height: 43px; + display: grid; + grid-template-columns: 17px minmax(0, 1fr) auto; + align-items: center; + gap: 3px 5px; + position: relative; + padding: 6px 7px; + border: 1px solid transparent; + border-radius: 7px; + color: var(--r2-ink); + text-align: left; + transform: translate3d(0, 0, 0); + transition: + transform 280ms cubic-bezier(.22, 1, .36, 1), + background-color 240ms ease, + border-color 240ms ease, + box-shadow 280ms cubic-bezier(.22, 1, .36, 1), + filter 240ms ease, + opacity 220ms ease; + will-change: transform; +} + +#rotationView .rotation-sector-chip.heat-strong { + border-color: rgba(37, 99, 235, .24); + background: #c5d7fb; + box-shadow: inset 3px 0 0 #5b87e8; +} + +#rotationView .rotation-sector-chip.heat-warm { + border-color: rgba(37, 99, 235, .12); + background: #e7efff; + box-shadow: inset 3px 0 0 #9bb8f2; +} + +#rotationView .rotation-sector-chip.heat-mild { + border-color: #e5eaf1; + background: #f6f8fb; + box-shadow: inset 3px 0 0 #d4dce8; +} + +#rotationView .rotation-sector-chip:hover { + z-index: 6; + border-color: rgba(37, 99, 235, .34); + filter: saturate(1.06); + transform: translate3d(2px, -2px, 0) scale(1.015); + box-shadow: inset 3px 0 0 var(--r2-blue), 0 7px 16px rgba(37, 74, 145, .16); +} + +#rotationView .rotation-rank { + width: 16px; + height: 16px; + grid-row: 1 / span 2; + display: grid; + place-items: center; + border-radius: 4px; + background: rgba(255, 255, 255, .72); + color: var(--r2-sub); + font-size: 9.5px; + font-weight: 700; +} + +#rotationView .rotation-rank.rank-1 { background: #e04536; color: #fff; } +#rotationView .rotation-rank.rank-2 { background: #f0714f; color: #fff; } +#rotationView .rotation-rank.rank-3 { background: #f5a623; color: #fff; } + +#rotationView .rotation-sector-chip strong { + min-width: 0; + overflow: visible; + color: var(--r2-ink); + font-size: 11.5px; + font-weight: 700; + line-height: 1.3; + overflow-wrap: anywhere; + white-space: normal; +} + +#rotationView .rotation-sector-chip small { + grid-column: 2 / span 2; + color: #5d6b82; + font-size: 9px; + line-height: 1.15; + white-space: nowrap; +} + +#rotationView .rotation-sector-chip small b { color: var(--r2-ink); font-weight: 700; } + +#rotationView .rotation-history.tracking .rotation-sector-chip:not(.selected) { opacity: .22; } + +#rotationView .rotation-history.tracking .rotation-sector-chip.selected { + opacity: 1; + border-color: var(--r2-blue); + box-shadow: inset 3px 0 0 var(--r2-blue), 0 0 0 1px rgba(37, 99, 235, .12); +} + +.rotation-cell-tooltip { + display: none; + position: absolute; + bottom: calc(100% + 4px); + left: 7px; + z-index: 20; + padding: 4px 8px; + border-radius: 5px; + background: var(--r2-ink); + color: #fff; + font-size: 10.5px; + font-weight: 400; + line-height: 1.4; + white-space: nowrap; + box-shadow: 0 5px 14px rgba(31, 41, 55, .18); + pointer-events: none; +} + +.rotation-sector-chip:hover .rotation-cell-tooltip, +.rotation-sector-chip:focus-visible .rotation-cell-tooltip { display: block; } + +.rotation-table-frame { width: 100%; overflow-x: auto; } + +#rotationView .rotation-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; +} + +#rotationView .rotation-table thead th { + height: 37px; + padding: 8px 12px; + border-bottom: 1px solid var(--r2-line); + background: #f8fafc; + color: var(--r2-sub); + font-size: 12px; + font-weight: 600; + text-align: left; + white-space: nowrap; +} + +#rotationView .rotation-table thead th.number { text-align: right; } + +#rotationView .rotation-table thead th[data-auto-sort] { + color: #4b5563; + cursor: pointer; + user-select: none; +} + +#rotationView .rotation-table thead th[data-auto-sort]::after { + content: "↕"; + margin-left: 4px; + color: var(--r2-faint); + font-size: 9px; +} + +#rotationView .rotation-table thead th.sort-asc::after { content: "▲"; color: var(--r2-blue); } +#rotationView .rotation-table thead th.sort-desc::after { content: "▼"; color: var(--r2-blue); } + +#rotationView .rotation-table tbody td { + height: 42px; + padding: 8px 12px; + border-bottom: 1px solid var(--r2-line-soft); + color: var(--r2-ink); + white-space: nowrap; +} + +#rotationView .rotation-table tbody tr:last-child td { border-bottom: 0; } +#rotationView .rotation-table tbody tr:hover td { background: #f8faff; } +#rotationView .rotation-detail-row { cursor: pointer; } +#rotationView .rotation-detail-row.selected td { background: var(--r2-blue-soft) !important; } +#rotationView .rotation-table .stock-name { font-size: 13px; font-weight: 700; } +#rotationView .rotation-table .muted { color: var(--r2-faint); } + +#rotationView .trend-tag { + display: inline-block; + padding: 2px 7px; + border-radius: 5px; + font-size: 11px; + line-height: 1.6; +} + +#rotationView .trend-hot { background: var(--r2-up-soft); color: var(--r2-up); } +#rotationView .trend-cool { background: #e8f4fd; color: #2563eb; } +#rotationView .trend-new { background: var(--r2-down-soft); color: var(--r2-down); } +#rotationView .trend-flat { background: #f3f4f6; color: var(--r2-sub); } + +#rotationView .rotation-strength { + min-width: 90px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; +} + +#rotationView .rotation-strength .strength-cell { + width: 64px; + height: 7px; + overflow: hidden; + border-radius: 4px; + background: #f0f2f5; +} + +#rotationView .rotation-strength .strength-cell i { + display: block; + height: 100%; + border-radius: inherit; + background: var(--r2-blue); +} + +#rotationView .rotation-strength b { + width: 24px; + color: var(--r2-ink); + text-align: right; + font-variant-numeric: tabular-nums; +} + +@media (max-width: 1100px) { + #rotationView .rotation-history { + grid-template-columns: repeat(9, 138px); + overflow-x: auto; + } + + #rotationView .rotation-tracker-copy { min-width: 0; flex-direction: column; gap: 2px; } + #rotationView .rotation-tracker-copy span { white-space: normal; } +} + +@media (max-width: 720px) { + .redesigned-rotation-view { padding: 0; } + .rotation-page-head { align-items: flex-start; flex-direction: column; } + .rotation-head-actions { width: 100%; margin-left: 0; justify-content: space-between; } + .rotation-page-head p { max-width: 100%; } + .rotation-card-head { align-items: flex-start; } + .rotation-card-head > div { align-items: flex-start; flex-direction: column; gap: 2px; } + #rotationView .rotation-legend { gap: 8px 12px; } + #rotationView .rotation-tracker { align-items: flex-start; flex-wrap: wrap; } + #rotationView .rotation-tracker-spark { flex: 0 0 auto; } + .rotation-track-cancel { margin-left: 0; } +} + +/* Stage 11: collection auction, transferred from index.html. */ +.redesigned-auction-view { + width: min(100%, 2200px); + min-width: 0; + margin-right: auto; + margin-left: auto; + padding: 14px 16px 18px; +} + +.auction-page-head-v2 { + min-width: 0; + min-height: 62px; + display: flex; + align-items: center; + gap: 18px; + margin-bottom: 12px; +} + +.auction-title-cluster { + min-width: 0; + flex: 1; + display: flex; + align-items: center; + gap: 14px; +} + +.auction-title-line { + flex: 0 0 auto; + display: grid; + gap: 3px; +} + +.auction-title-line h2 { + margin: 0; + color: var(--r2-ink); + font-size: 17px; + font-weight: 800; + letter-spacing: 0; +} + +.auction-title-line span { + color: var(--r2-faint); + font-size: 12px; + white-space: nowrap; +} + +.auction-phase-notice-v2 { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; + padding: 6px 10px; + border: 1px solid var(--r2-line); + border-radius: 7px; + background: #fff; + color: var(--r2-sub); + font-size: 11.5px; +} + +.auction-phase-notice-v2 .auction-phase-marker { + width: 7px; + height: 7px; + flex: 0 0 7px; + border-radius: 50%; + background: var(--r2-faint); + box-shadow: none; +} + +.auction-phase-notice-v2 strong { + flex: 0 0 auto; + color: var(--r2-ink); + font-size: 12px; + font-weight: 700; +} + +.auction-phase-notice-v2 > span:not(.auction-phase-marker) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auction-phase-notice-v2 time { + flex: 0 0 auto; + margin-left: 3px; + color: var(--r2-faint); + font-size: 10.5px; + white-space: nowrap; +} + +.auction-phase-notice-v2[data-phase="selection"] { border-color: #f2d6a4; background: #fffaf1; } +.auction-phase-notice-v2[data-phase="selection"] .auction-phase-marker { background: #d97706; animation: auction-pulse 1.8s ease-in-out infinite; } +.auction-phase-notice-v2[data-phase="finalized"] { border-color: #cfe0d7; background: #f4fbf7; } +.auction-phase-notice-v2[data-phase="finalized"] .auction-phase-marker { background: var(--r2-down); } +.auction-phase-notice-v2[data-phase="observing"] .auction-phase-marker { background: var(--r2-blue); animation: auction-pulse 1.8s ease-in-out infinite; } + +@keyframes auction-pulse { + 0%, 100% { opacity: .42; box-shadow: 0 0 0 0 rgba(37, 99, 235, .18); } + 50% { opacity: 1; box-shadow: 0 0 0 4px rgba(37, 99, 235, 0); } +} + +.auction-header-actions-v2 { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; +} + +.auction-summary-v2 { + min-width: 390px; + display: grid; + grid-template-columns: repeat(4, minmax(82px, 1fr)); + border: 1px solid var(--r2-line); + border-radius: 9px; + background: #fff; + box-shadow: var(--r2-shadow); +} + +.auction-summary-v2 > div { + min-width: 0; + min-height: 48px; + display: grid; + align-content: center; + gap: 2px; + padding: 6px 10px; + border-right: 1px solid var(--r2-line-soft); +} + +.auction-summary-v2 > div:last-child { border-right: 0; } +.auction-summary-v2 span { color: var(--r2-faint); font-size: 10.5px; white-space: nowrap; } +.auction-summary-v2 strong { color: var(--r2-ink); font-size: 14px; font-weight: 750; font-variant-numeric: tabular-nums; white-space: nowrap; } +.auction-summary-v2 strong.up { color: var(--r2-up); } + +.auction-refresh-button, +.auction-export-button { + min-height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 5px 11px; + border: 1px solid var(--r2-line); + border-radius: 7px; + background: #fff; + color: #374151; + font-size: 12px; + font-weight: 500; + white-space: nowrap; +} + +.auction-refresh-button:hover, +.auction-export-button:hover { border-color: var(--r2-blue-line); color: var(--r2-blue); } +.auction-refresh-button .lucide { width: 14px; height: 14px; } + +.auction-workspace-v2 { + min-width: 0; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + align-items: stretch; + gap: 12px; +} + +.auction-primary-card, +.auction-side-card { + min-width: 0; + overflow: hidden; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} + +.auction-primary-card { + min-height: 0; + display: flex; + flex-direction: column; +} + +.auction-tabs-v2 { + flex: 0 0 auto; + min-height: 45px; + display: flex; + align-items: stretch; + gap: 2px; + padding: 0 14px; + border-bottom: 1px solid var(--r2-line-soft); +} + +.auction-tabs-v2 button { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 4px; + position: relative; + padding: 0 14px; + border: 0; + background: transparent; + color: var(--r2-sub); + font-size: 13px; + font-weight: 500; + white-space: nowrap; +} + +.auction-tabs-v2 button::after { + content: ""; + position: absolute; + right: 12px; + bottom: -1px; + left: 12px; + height: 2px; + border-radius: 2px 2px 0 0; + background: transparent; +} + +.auction-tabs-v2 button strong { + color: var(--r2-faint); + font-size: 11px; + font-weight: 400; + font-variant-numeric: tabular-nums; +} + +.auction-tabs-v2 button:hover { color: var(--r2-blue); } +.auction-tabs-v2 button.active { color: var(--r2-blue); font-weight: 650; } +.auction-tabs-v2 button.active::after { background: var(--r2-blue); } +.auction-tabs-v2 button.active strong { color: var(--r2-blue); } + +.auction-tools-v2 { + flex: 0 0 auto; + min-height: 47px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 7px 12px; + border-bottom: 1px solid var(--r2-line); + background: #fafbfc; +} + +.auction-expectation-v2, +.auction-tool-actions, +.auction-filter-segments { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.auction-expectation-v2[hidden] { display: none; } +.auction-expectation-v2 > span { flex: 0 0 auto; color: var(--r2-sub); font-size: 11px; } + +.auction-filter-segments { + gap: 2px; + padding: 2px; + border-radius: 8px; + background: #f0f2f5; +} + +.auction-filter-segments button { + min-height: 27px; + padding: 4px 11px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--r2-sub); + font-size: 12px; +} + +.auction-filter-segments button.active { + background: #fff; + color: var(--r2-ink); + font-weight: 600; + box-shadow: 0 1px 2px rgba(0, 0, 0, .08); +} + +.auction-search-v2 { + min-width: 0; + height: 31px; + display: flex; + align-items: center; + gap: 6px; + padding: 0 9px; + border: 1px solid var(--r2-line); + border-radius: 7px; + background: #fff; +} + +.auction-search-v2 .lucide { width: 14px; height: 14px; color: var(--r2-faint); } + +.auction-search-v2 input { + width: 180px; + min-width: 0; + padding: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--r2-ink); + font-size: 12px; +} + +.auction-search-v2:focus-within { border-color: var(--r2-blue-line); box-shadow: 0 0 0 2px rgba(37, 99, 235, .08); } + +.auction-tabs-v2 button:focus-visible, +.auction-filter-segments button:focus-visible, +.auction-refresh-button:focus-visible, +.auction-export-button:focus-visible { + outline: 2px solid rgba(37, 99, 235, .28); + outline-offset: 2px; +} + +.auction-table-frame-v2 { + min-height: 0; + flex: 1 1 auto; + max-height: none; + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.auction-table-v2 { + width: 100%; + min-width: 790px; + border-collapse: collapse; + font-size: 12.5px; +} + +.auction-table-v2 thead th { + position: sticky; + top: 0; + z-index: 2; + height: 35px; + padding: 7px 10px; + border-bottom: 1px solid var(--r2-line); + background: #f8fafc; + color: var(--r2-sub); + font-size: 11.5px; + font-weight: 600; + text-align: left; + white-space: nowrap; +} + +.auction-table-v2 thead th.number { text-align: right; } +.auction-table-v2 thead th.sortable { cursor: pointer; user-select: none; } +.auction-table-v2 thead th.sortable:hover { color: var(--r2-blue); } +.auction-table-v2 thead th.sortable span { margin-left: 3px; color: var(--r2-faint); font-size: 9px; } +.auction-table-v2 thead th.sorted span { color: var(--r2-blue); } + +.auction-table-v2 tbody td { + height: 47px; + padding: 7px 10px; + border-bottom: 1px solid var(--r2-line-soft); + color: var(--r2-ink); + white-space: nowrap; +} + +.auction-table-v2 tbody tr:last-child td { border-bottom: 0; } +.auction-table-v2 tbody tr:hover td { background: #f8faff; } + +.auction-stock-cell-v2 { + min-width: 112px; + display: flex; + align-items: baseline; + gap: 6px; +} + +.auction-stock-cell-v2 strong { color: var(--r2-ink); font-size: 13px; font-weight: 750; } +.auction-stock-cell-v2 small { color: var(--r2-faint); font-size: 10.5px; font-weight: 400; } + +.auction-context-cell-v2 { + min-width: 130px; + max-width: 190px; + display: grid; + gap: 3px; +} + +.auction-context-cell-v2 > strong { + overflow: hidden; + color: var(--r2-ink); + font-size: 12px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auction-source-tags-v2 { + min-width: 0; + display: flex; + gap: 3px; + overflow: hidden; +} + +.auction-source-tags-v2 b { + flex: 0 0 auto; + padding: 1px 4px; + border-radius: 3px; + background: #f0f2f5; + color: var(--r2-sub); + font-size: 9.5px; + font-weight: 500; +} + +.auction-source-tags-v2 b:nth-child(n + 2) { background: var(--r2-blue-soft); color: #4664a0; } + +#auctionView .auction-core-tags { display: inline-flex; flex-wrap: wrap; gap: 4px; } +#auctionView .auction-core-tags b { padding: 2px 5px; border: 0; border-radius: 4px; background: var(--r2-amber-soft); color: var(--r2-amber); font-size: 10px; font-weight: 700; } +.auction-identity-empty { display: inline-block; width: 1px; height: 18px; } + +#auctionView .auction-score { color: #315f7b; font-weight: 750; } +#auctionView .auction-volume-ratio { color: var(--r2-sub); } + +#auctionView .auction-expectation, +#auctionView .auction-one-price-tag { + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 1px 7px; + border: 0; + border-radius: 5px; + font-size: 11px; + font-weight: 600; +} + +#auctionView .auction-expectation.above { background: var(--r2-up-soft); color: var(--r2-up); } +#auctionView .auction-expectation.matched { background: #f3f4f6; color: #4b5563; } +#auctionView .auction-expectation.below { background: var(--r2-down-soft); color: var(--r2-down); } +#auctionView .auction-one-price-tag { background: var(--r2-amber-soft); color: var(--r2-amber); } + +.auction-side-v2 { + min-width: 0; + min-height: 0; + max-height: 100%; + display: flex; + flex-direction: column; + gap: 12px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + scrollbar-width: none; +} + +.auction-side-v2::-webkit-scrollbar { display: none; } +.auction-side-v2 > .auction-side-card { flex: 0 0 auto; } + +.auction-card-head-v2 { + min-height: 44px; + display: flex; + align-items: center; + gap: 8px; + padding: 9px 14px; + border-bottom: 1px solid var(--r2-line-soft); +} + +.auction-card-head-v2 > div { min-width: 0; display: flex; align-items: baseline; gap: 7px; } +.auction-card-head-v2 h3 { margin: 0; color: var(--r2-ink); font-size: 14px; font-weight: 700; } +.auction-card-head-v2 > div > span { color: var(--r2-faint); font-size: 11px; } + +.auction-card-tag { + flex: 0 0 auto; + margin-left: auto; + padding: 2px 7px; + border-radius: 5px; + background: #f3f4f6; + color: var(--r2-sub); + font-size: 10.5px; + white-space: nowrap; +} + +.auction-theme-list-v2 { min-height: 0; padding: 5px 14px; } + +#auctionView .auction-theme-row { + min-height: 48px; + display: grid; + grid-template-columns: 72px minmax(0, 1fr) auto 54px; + align-items: center; + gap: 6px; + padding: 7px 0; + border-bottom: 1px dashed var(--r2-line-soft); +} + +#auctionView .auction-theme-row:last-child { border-bottom: 0; } +.auction-theme-name { overflow: hidden; color: var(--r2-ink); font-size: 12.5px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } +.auction-theme-info { min-width: 0; overflow: hidden; color: var(--r2-sub); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } + +#auctionView .auction-theme-status { + min-height: 21px; + padding: 1px 6px; + border: 0; + border-radius: 5px; + font-size: 10.5px; + font-weight: 600; +} + +#auctionView .auction-theme-status.strong { background: #dbe7ff; color: #2858bc; } +#auctionView .auction-theme-status.steady { background: #e9f0ff; color: #4664a0; } +#auctionView .auction-theme-status.mixed { background: var(--r2-amber-soft); color: var(--r2-amber); } +#auctionView .auction-theme-status.weak { background: #f3f4f6; color: var(--r2-sub); } + +.auction-theme-median { + display: grid; + justify-items: end; + color: var(--r2-sub); + font-size: 11.5px; + font-weight: 650; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.auction-theme-median small { color: var(--r2-faint); font-size: 9.5px; font-weight: 400; } + +.auction-new-theme-v2 { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 9px; + padding: 9px 14px 11px; + border-top: 1px solid var(--r2-line-soft); + color: var(--r2-sub); + font-size: 11px; +} + +.auction-new-theme-v2 > strong { color: var(--r2-ink); font-size: 11.5px; } +.auction-theme-chips-v2 { min-width: 0; display: flex; flex-wrap: wrap; gap: 4px; } +.auction-theme-chips-v2 > span { padding: 2px 6px; border-radius: 4px; background: var(--r2-blue-soft); color: #4664a0; font-size: 10px; } +.auction-theme-chips-v2 > span strong { color: var(--r2-up); } +.auction-theme-chips-v2 > small { color: var(--r2-faint); font-size: 10.5px; line-height: 1.5; } + +.auction-volume-body { padding: 11px 14px 7px; } +.auction-volume-summary { display: flex; align-items: baseline; gap: 14px; margin-bottom: 8px; } +.auction-amount-value-v2 { flex: 0 0 auto; color: var(--r2-ink); font-size: 22px; font-weight: 800; font-variant-numeric: tabular-nums; } + +.auction-amount-compare-v2 { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; +} + +.auction-amount-compare-v2 span { color: var(--r2-sub); font-size: 10.5px; white-space: nowrap; } +.auction-amount-compare-v2 strong { margin-left: 4px; color: var(--r2-ink); font-size: 11px; font-weight: 650; } + +.auction-amount-trend-v2 { + height: 112px; + display: flex; + align-items: stretch; + gap: 5px; + position: relative; + padding: 6px 0 0; +} + +.auction-amount-trend-v2 .auction-amount-day { + min-width: 0; + flex: 1; + display: grid; + grid-template-rows: minmax(0, 1fr) 18px; + align-items: end; + gap: 3px; +} + +.auction-amount-trend-v2 .auction-amount-day > span { + width: 72%; + min-height: 4px; + justify-self: center; + border-radius: 3px 3px 0 0; + background: #c9d6ee; + transition: opacity 180ms ease, transform 180ms ease; + transform-origin: bottom; +} + +.auction-amount-trend-v2 .auction-amount-day.current > span { background: var(--r2-up); } +.auction-amount-trend-v2 .auction-amount-day:hover > span { opacity: .78; transform: scaleY(1.03); } +.auction-amount-trend-v2 .auction-amount-day small { overflow: hidden; color: var(--r2-faint); font-size: 9px; text-align: center; white-space: nowrap; } +.auction-amount-trend-v2 .auction-amount-day.current small { color: var(--r2-up); font-weight: 700; } + +.auction-amount-trend-v2 .auction-amount-average { + position: absolute; + right: 0; + left: 0; + z-index: 1; + border-top: 1.5px dashed #f59e0b; + pointer-events: none; +} + +.auction-amount-trend-v2 .auction-amount-average small { + position: absolute; + top: -15px; + right: 0; + padding-left: 3px; + background: #fff; + color: #d97706; + font-size: 9.5px; +} + +.auction-volume-legend { + display: flex; + gap: 13px; + padding: 7px 14px 10px; + border-top: 1px solid var(--r2-line-soft); + color: var(--r2-faint); + font-size: 10px; +} + +.auction-volume-legend span { display: inline-flex; align-items: center; gap: 4px; } +.auction-volume-legend i { width: 10px; height: 7px; display: inline-block; border-radius: 2px; background: #c9d6ee; } +.auction-volume-legend i.current { background: var(--r2-up); } +.auction-volume-legend i.average { height: 0; border-top: 1.5px dashed #f59e0b; border-radius: 0; background: transparent; } + +@media (max-width: 1260px) { + .auction-page-head-v2 { align-items: stretch; flex-direction: column; gap: 9px; } + .auction-header-actions-v2 { justify-content: space-between; } + .auction-summary-v2 { width: min(100%, 500px); } +} + +@media (max-width: 1050px) { + .auction-workspace-v2 { grid-template-columns: minmax(0, 1fr); } + .auction-side-v2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 720px) { + .redesigned-auction-view { padding: 0; } + .auction-title-cluster { align-items: flex-start; flex-direction: column; gap: 8px; } + .auction-phase-notice-v2 { width: 100%; flex-wrap: wrap; } + .auction-phase-notice-v2 > span:not(.auction-phase-marker) { width: calc(100% - 110px); } + .auction-phase-notice-v2 time { margin-left: 14px; } + .auction-header-actions-v2 { align-items: stretch; flex-direction: column; } + .auction-summary-v2 { min-width: 0; width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); } + .auction-summary-v2 > div:nth-child(2) { border-right: 0; } + .auction-summary-v2 > div:nth-child(-n + 2) { border-bottom: 1px solid var(--r2-line-soft); } + .auction-tabs-v2 { overflow-x: auto; } + .auction-tabs-v2 button { flex: 0 0 auto; padding: 0 10px; } + .auction-tools-v2 { align-items: stretch; flex-direction: column; } + .auction-expectation-v2 { align-items: flex-start; flex-direction: column; } + .auction-filter-segments { width: 100%; } + .auction-filter-segments button { flex: 1; padding-right: 5px; padding-left: 5px; } + .auction-tool-actions { width: 100%; } + .auction-search-v2 { flex: 1; } + .auction-search-v2 input { width: 100%; } + .auction-table-frame-v2 { min-height: 360px; max-height: none; } + .auction-side-v2 { grid-template-columns: minmax(0, 1fr); } + .auction-volume-summary { align-items: flex-start; flex-direction: column; gap: 4px; } + #auctionView .auction-theme-row { grid-template-columns: 66px minmax(0, 1fr) auto 50px; } +} + +@media (prefers-reduced-motion: reduce) { + .auction-phase-notice-v2 .auction-phase-marker { animation: none !important; } + .auction-amount-trend-v2 .auction-amount-day > span { transition: none; } +} + +/* Desktop viewport ownership: the shell stays fixed and each workspace owns one scroll axis. */ +@media (min-width: 721px) { + html, + body { + height: 100%; + min-height: 0; + } + + body { + height: 100dvh; + overflow: hidden; + } + + .app-main { + min-height: 0; + overscroll-behavior: contain; + } + + body[data-active-view="auctionView"] .app-main { + display: flex; + flex-direction: column; + overflow: hidden; + } + + body[data-active-view="auctionView"] .overview-strip { + flex: 0 0 auto; + } + + body[data-active-view="auctionView"] #auctionView.active-view { + min-height: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; + } + + body[data-active-view="auctionView"] .auction-page-head-v2 { + flex: 0 0 auto; + } + + body[data-active-view="auctionView"] .auction-workspace-v2 { + min-height: 0; + flex: 1 1 auto; + } +} + +/* At constrained desktop widths the evidence rail moves below and the main pane is the only scroller. */ +@media (min-width: 721px) and (max-width: 1320px) { + body[data-active-view="auctionView"] .app-main { + display: block; + overflow-x: hidden; + overflow-y: auto; + } + + body[data-active-view="auctionView"] #auctionView.active-view { + display: block; + } + + .auction-workspace-v2 { + grid-template-columns: minmax(0, 1fr); + } + + .auction-side-v2 { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + max-height: none; + overflow: visible; + scrollbar-gutter: auto; + } + + .auction-table-frame-v2 { + overflow: visible; + scrollbar-gutter: auto; + } +} + +@media (min-width: 721px) and (max-height: 900px) { + .redesigned-auction-view { padding: 10px 12px 12px; } + .auction-page-head-v2 { min-height: 52px; gap: 12px; margin-bottom: 8px; } + .auction-summary-v2 > div { min-height: 43px; padding-top: 4px; padding-bottom: 4px; } + .auction-tabs-v2 { min-height: 41px; } + .auction-tools-v2 { min-height: 43px; padding-top: 5px; padding-bottom: 5px; } + .auction-table-v2 tbody td { height: 43px; padding-top: 6px; padding-bottom: 6px; } +} + +@media (min-width: 1321px) and (min-height: 1400px) { + body[data-active-view="auctionView"] .auction-workspace-v2 { align-items: start; } +} + +/* Stage 12: theme library. Keep the richer product information architecture, + while rebuilding its hierarchy with the approved compact visual system. */ +.redesigned-theme-view { + width: min(100%, 2200px); + margin: 0 auto; + padding: 12px 16px 14px; +} + +.theme-page-head-v2 { + min-height: 52px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 10px; +} + +.theme-title-v2, +.theme-head-actions-v2, +.theme-title-v2 > div, +.theme-detail-name-line-v2, +.theme-chart-heading-v2, +.theme-chart-legend-v2, +.theme-members-heading-v2, +.theme-members-heading-v2 > div { + display: flex; + align-items: center; +} + +.theme-title-v2 { min-width: 0; gap: 12px; } +.theme-title-v2 > div { min-width: 0; gap: 9px; } +.theme-title-v2 h2 { margin: 0; color: var(--r2-ink); font-size: 19px; font-weight: 750; } +.theme-title-v2 > div > span { color: var(--r2-sub); font-size: 12px; } + +.theme-date-v2 { + padding: 4px 8px; + border-radius: 5px; + background: var(--r2-amber-soft); + color: var(--r2-amber); + font-size: 11px; + font-weight: 650; + white-space: nowrap; +} + +.theme-head-actions-v2 { flex: 0 0 auto; gap: 8px; } +.theme-search-v2 { + width: 250px; + height: 34px; + display: flex; + align-items: center; + gap: 7px; + padding: 0 10px; + border: 1px solid #d8dde5; + border-radius: 7px; + background: #fff; + color: var(--r2-faint); + transition: border-color 160ms ease, box-shadow 160ms ease; +} + +.theme-search-v2:focus-within { + border-color: #96b5f2; + box-shadow: 0 0 0 3px rgba(37, 99, 235, .09); +} + +.theme-search-v2 .lucide { width: 15px; height: 15px; } +.theme-search-v2 input { + min-width: 0; + width: 100%; + height: 100%; + padding: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--r2-ink); + font: inherit; +} + +.theme-search-v2 input::placeholder { color: var(--r2-faint); } +.theme-refresh-v2 { min-height: 34px; padding: 0 12px; border-radius: 7px; } +.theme-refresh-v2 .lucide { width: 15px; height: 15px; } + +.theme-summary-v2 { + min-height: 58px; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-bottom: 12px; + overflow: hidden; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} + +.theme-summary-v2 > div { + min-width: 0; + display: grid; + align-content: center; + gap: 3px; + padding: 8px 15px; + border-right: 1px solid var(--r2-line); +} + +.theme-summary-v2 > div:last-child { border-right: 0; } +.theme-summary-v2 span { color: var(--r2-sub); font-size: 11px; } +.theme-summary-v2 strong { color: var(--r2-ink); font-size: 19px; font-weight: 750; font-variant-numeric: tabular-nums; } +.theme-summary-v2 strong small { margin-left: 3px; color: currentColor; font-size: 11px; font-weight: 600; } +.theme-summary-v2 strong.up { color: var(--r2-up); } +.theme-summary-v2 strong.down { color: var(--r2-down); } +.theme-summary-v2 strong.warning { color: var(--r2-amber); } + +.theme-library-workspace-v2 { + min-height: 0; + display: grid; + grid-template-columns: 318px minmax(0, 1fr); + align-items: stretch; + gap: 12px; +} + +.theme-directory-card-v2, +.theme-market-card-v2, +.theme-members-card-v2, +.theme-detail-empty-v2 { + min-width: 0; + overflow: hidden; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} + +.theme-directory-card-v2 { + min-height: 0; + display: flex; + flex-direction: column; +} + +.theme-card-head-v2 { + min-height: 55px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 13px; + border-bottom: 1px solid var(--r2-line-soft); +} + +.theme-card-head-v2 h3, +.theme-members-heading-v2 h3, +.theme-chart-heading-v2 h4 { margin: 0; color: var(--r2-ink); } +.theme-card-head-v2 h3 { font-size: 14px; font-weight: 700; } +.theme-card-head-v2 span { display: block; margin-top: 2px; color: var(--r2-faint); font-size: 10.5px; } +.theme-card-head-v2 > strong { + flex: 0 0 auto; + padding: 3px 7px; + border-radius: 5px; + background: #f3f4f6; + color: var(--r2-sub); + font-size: 10.5px; + font-weight: 650; +} + +.theme-directory-labels-v2 { + min-height: 28px; + display: grid; + grid-template-columns: minmax(0, 1fr) 74px; + align-items: center; + padding: 0 13px 0 45px; + border-bottom: 1px solid var(--r2-line-soft); + background: #fafbfc; + color: var(--r2-faint); + font-size: 10px; +} + +.theme-directory-labels-v2 span:last-child { text-align: right; } +.theme-directory-v2 { + min-height: 0; + flex: 1 1 auto; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.theme-directory-item-v2 { + width: 100%; + min-height: 58px; + display: grid; + grid-template-columns: 24px minmax(0, 1fr) 68px; + align-items: center; + gap: 8px; + position: relative; + padding: 7px 12px; + border: 0; + border-bottom: 1px solid var(--r2-line-soft); + background: transparent; + color: var(--r2-ink); + cursor: pointer; + text-align: left; + transition: background-color 160ms ease, color 160ms ease; +} + +.theme-directory-item-v2::before { + content: ""; + position: absolute; + inset: 9px auto 9px 0; + width: 3px; + border-radius: 0 3px 3px 0; + background: transparent; + transform: scaleY(.45); + transition: background-color 160ms ease, transform 180ms ease; +} + +.theme-directory-item-v2:hover { background: #f7f9fc; } +.theme-directory-item-v2.active { background: var(--r2-blue-soft); } +.theme-directory-item-v2.active::before { background: var(--r2-blue); transform: scaleY(1); } +.theme-directory-item-v2:focus-visible { z-index: 1; outline: 2px solid var(--r2-blue); outline-offset: -2px; } + +.theme-rank-v2 { + color: var(--r2-faint); + font-size: 10.5px; + font-variant-numeric: tabular-nums; + text-align: center; +} + +.theme-directory-item-v2:nth-child(-n + 3) .theme-rank-v2 { color: var(--r2-amber); font-weight: 750; } +.theme-directory-copy-v2 { min-width: 0; display: grid; gap: 3px; } +.theme-directory-copy-v2 strong { overflow: hidden; color: var(--r2-ink); font-size: 13px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } +.theme-directory-copy-v2 small { overflow: hidden; color: var(--r2-faint); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } +.theme-directory-item-v2 > b { font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums; text-align: right; } + +.theme-detail-column-v2, +.theme-detail-stack-v2 { min-width: 0; min-height: 0; } +.theme-detail-column-v2 { display: flex; } +.theme-detail-empty-v2 { width: 100%; min-height: 420px; display: grid; place-items: center; color: var(--r2-faint); } +.theme-detail-stack-v2:not([hidden]) { + width: 100%; + display: grid; + grid-template-rows: minmax(348px, 1.35fr) minmax(190px, .85fr); + gap: 12px; +} + +.theme-market-card-v2, +.theme-members-card-v2 { min-height: 0; display: flex; flex-direction: column; } +.theme-detail-heading-v2 { + min-height: 64px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 10px 15px; + border-bottom: 1px solid var(--r2-line-soft); +} + +.theme-detail-identity-v2 { min-width: 0; } +.theme-detail-kicker-v2 { color: var(--r2-faint); font-size: 10.5px; } +.theme-detail-name-line-v2 { min-width: 0; gap: 8px; margin-top: 3px; } +.theme-detail-name-line-v2 h3 { margin: 0; overflow: hidden; color: var(--r2-ink); font-size: 18px; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; } +.theme-detail-name-line-v2 small { + flex: 0 0 auto; + padding: 2px 6px; + border-radius: 4px; + background: #f3f4f6; + color: var(--r2-sub); + font-size: 10px; +} + +.theme-change-v2 { flex: 0 0 auto; display: grid; justify-items: end; gap: 2px; } +.theme-change-v2 span { color: var(--r2-faint); font-size: 10.5px; } +.theme-change-v2 strong { font-size: 22px; font-weight: 750; font-variant-numeric: tabular-nums; } + +.theme-detail-metrics-v2 { + min-height: 52px; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + border-bottom: 1px solid var(--r2-line-soft); + background: #fcfcfd; +} + +.theme-detail-metrics-v2 > div { + min-width: 0; + display: grid; + align-content: center; + gap: 3px; + padding: 7px 13px; + border-right: 1px solid var(--r2-line-soft); +} + +.theme-detail-metrics-v2 > div:last-child { border-right: 0; } +.theme-detail-metrics-v2 span { color: var(--r2-faint); font-size: 10px; } +.theme-detail-metrics-v2 strong { color: var(--r2-ink); font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; } +.theme-detail-metrics-v2 strong.up { color: var(--r2-up); } +.theme-detail-metrics-v2 strong.down { color: var(--r2-down); } + +.theme-chart-heading-v2 { + min-height: 39px; + justify-content: space-between; + gap: 12px; + padding: 6px 14px 4px; +} + +.theme-chart-heading-v2 h4 { display: inline; font-size: 12px; font-weight: 700; } +.theme-chart-heading-v2 > div:first-child > span { margin-left: 6px; color: var(--r2-faint); font-size: 10px; } +.theme-chart-legend-v2 { gap: 12px; color: var(--r2-faint); font-size: 9.5px; } +.theme-chart-legend-v2 span { display: inline-flex; align-items: center; gap: 4px; } +.theme-chart-legend-v2 i { width: 8px; height: 8px; display: inline-block; border-radius: 2px; } +.theme-chart-legend-v2 i.up { background: var(--r2-up); } +.theme-chart-legend-v2 i.down { background: var(--r2-down); } +.theme-chart-legend-v2 i.volume { height: 5px; background: #a8b2c1; } + +.theme-chart-shell-v2 { min-height: 220px; flex: 1 1 auto; padding: 0 10px 8px; } +.theme-chart-shell-v2 canvas { width: 100%; height: 100%; display: block; } + +.theme-members-heading-v2 { + min-height: 50px; + justify-content: space-between; + gap: 14px; + padding: 8px 13px; + border-bottom: 1px solid var(--r2-line-soft); +} + +.theme-members-heading-v2 > div { min-width: 0; gap: 8px; } +.theme-members-heading-v2 h3 { font-size: 14px; font-weight: 700; } +.theme-members-heading-v2 span { overflow: hidden; color: var(--r2-faint); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } +.theme-members-heading-v2 > strong { flex: 0 0 auto; color: var(--r2-sub); font-size: 10.5px; font-weight: 600; } + +.theme-members-frame-v2 { + min-height: 0; + flex: 1 1 auto; + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.theme-members-table-v2 { min-width: 650px; } +.theme-members-table-v2 thead th { + position: sticky; + top: 0; + z-index: 2; + height: 34px; + padding: 6px 11px; + border-bottom-color: var(--r2-line); + background: #fafbfc; + color: var(--r2-sub); + font-size: 10.5px; +} + +.theme-members-table-v2 tbody td { height: 39px; padding: 7px 11px; font-size: 11.5px; } +.theme-members-table-v2 tbody tr { cursor: pointer; } +.theme-members-table-v2 tbody tr:hover { background: #f7f9fc; } +.theme-members-table-v2 .stock-name { color: var(--r2-ink); font-weight: 650; } +.theme-members-table-v2 .stock-code { color: #405573; } + +@media (min-width: 1181px) { + body[data-active-view="themeLibraryView"] .app-main { + display: flex; + flex-direction: column; + overflow: hidden; + } + + body[data-active-view="themeLibraryView"] .overview-strip { flex: 0 0 auto; } + body[data-active-view="themeLibraryView"] #themeLibraryView.active-view { + min-height: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; + } + + body[data-active-view="themeLibraryView"] .theme-page-head-v2, + body[data-active-view="themeLibraryView"] .theme-summary-v2 { flex: 0 0 auto; } + body[data-active-view="themeLibraryView"] .theme-library-workspace-v2 { flex: 1 1 auto; } +} + +@media (min-width: 721px) and (max-width: 1180px) { + body[data-active-view="themeLibraryView"] .app-main { overflow-x: hidden; overflow-y: auto; } + .theme-library-workspace-v2 { grid-template-columns: minmax(0, 1fr); } + .theme-directory-card-v2 { max-height: 330px; } + .theme-directory-v2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } + .theme-directory-item-v2:nth-child(odd) { border-right: 1px solid var(--r2-line-soft); } + .theme-detail-stack-v2:not([hidden]) { grid-template-rows: 430px 360px; } +} + +@media (min-width: 721px) and (max-height: 900px) { + .redesigned-theme-view { padding-top: 9px; padding-bottom: 10px; } + .theme-page-head-v2 { min-height: 45px; margin-bottom: 7px; } + .theme-summary-v2 { min-height: 51px; margin-bottom: 9px; } + .theme-summary-v2 > div { padding-top: 5px; padding-bottom: 5px; } + .theme-card-head-v2 { min-height: 50px; } + .theme-directory-labels-v2 { min-height: 25px; } + .theme-directory-item-v2 { min-height: 52px; } + .theme-detail-stack-v2:not([hidden]) { grid-template-rows: minmax(330px, 1.35fr) minmax(180px, .85fr); gap: 9px; } + .theme-detail-heading-v2 { min-height: 58px; } + .theme-detail-metrics-v2 { min-height: 47px; } + .theme-chart-heading-v2 { min-height: 34px; } + .theme-chart-shell-v2 { min-height: 185px; } + .theme-members-heading-v2 { min-height: 44px; } + .theme-members-table-v2 tbody td { height: 35px; padding-top: 5px; padding-bottom: 5px; } +} + +@media (max-width: 720px) { + .redesigned-theme-view { padding: 10px; } + .theme-page-head-v2 { align-items: stretch; flex-direction: column; gap: 9px; margin-bottom: 10px; } + .theme-title-v2 { align-items: flex-start; justify-content: space-between; gap: 8px; } + .theme-title-v2 > div { align-items: flex-start; flex-direction: column; gap: 2px; } + .theme-head-actions-v2 { width: 100%; } + .theme-search-v2 { min-width: 0; width: auto; flex: 1 1 auto; } + .theme-refresh-v2 { flex: 0 0 auto; } + .theme-summary-v2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .theme-summary-v2 > div:nth-child(2) { border-right: 0; } + .theme-summary-v2 > div:nth-child(-n + 2) { border-bottom: 1px solid var(--r2-line); } + .theme-library-workspace-v2 { grid-template-columns: minmax(0, 1fr); } + .theme-directory-card-v2 { max-height: 360px; } + .theme-detail-stack-v2:not([hidden]) { display: flex; flex-direction: column; gap: 10px; } + .theme-market-card-v2 { min-height: 430px; } + .theme-detail-heading-v2 { align-items: flex-end; } + .theme-detail-name-line-v2 { align-items: flex-start; flex-direction: column; gap: 2px; } + .theme-detail-metrics-v2 { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .theme-detail-metrics-v2 > div { border-bottom: 1px solid var(--r2-line-soft); } + .theme-detail-metrics-v2 > div:nth-child(3) { border-right: 0; } + .theme-detail-metrics-v2 > div:nth-child(n + 4) { border-bottom: 0; } + .theme-chart-heading-v2 { align-items: flex-start; flex-direction: column; gap: 4px; } + .theme-chart-shell-v2 { min-height: 245px; } + .theme-members-card-v2 { min-height: 420px; } + .theme-members-heading-v2 > div { align-items: flex-start; flex-direction: column; gap: 2px; } +} + +@media (prefers-reduced-motion: reduce) { + .theme-search-v2, + .theme-directory-item-v2, + .theme-directory-item-v2::before { transition: none; } +} + +/* Stage 13: popularity ranking. The prototype's three-glance hierarchy is + transferred directly while preserving concepts, movement and stock detail. */ +.redesigned-popularity-view { + width: min(100%, 2200px); + margin: 0 auto; + padding: 12px 16px 14px; +} + +.popularity-page-head-v2 { + min-height: 52px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 10px; +} + +.popularity-title-v2, +.popularity-head-actions-v2, +.popularity-source-tabs-v2, +.popularity-table-head-v2, +.popularity-table-head-v2 > div, +.popularity-search-v2, +.popularity-stock-v2 { display: flex; align-items: center; } + +.popularity-title-v2 { min-width: 0; gap: 9px; } +.popularity-title-v2 h2 { margin: 0; color: var(--r2-ink); font-size: 19px; font-weight: 750; } +.popularity-title-v2 > span { color: var(--r2-sub); font-size: 12px; } +.popularity-title-v2 > strong { + padding: 4px 8px; + border-radius: 5px; + background: var(--r2-amber-soft); + color: var(--r2-amber); + font-size: 11px; + font-weight: 650; + white-space: nowrap; +} +.popularity-title-v2 > small { color: var(--r2-faint); font-size: 10.5px; white-space: nowrap; } + +.popularity-head-actions-v2 { flex: 0 0 auto; gap: 8px; } +.popularity-source-tabs-v2 { + min-height: 34px; + padding: 3px; + border: 1px solid var(--r2-line); + border-radius: 8px; + background: #f4f5f7; +} + +.popularity-source-tabs-v2 button { + min-height: 27px; + padding: 0 13px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--r2-sub); + font-size: 11px; + cursor: pointer; +} + +.popularity-source-tabs-v2 button:hover { color: var(--r2-ink); } +.popularity-source-tabs-v2 button.active { + background: #fff; + color: var(--r2-ink); + font-weight: 700; + box-shadow: 0 1px 3px rgba(16, 24, 40, .09); +} + +.popularity-source-tabs-v2 button:focus-visible { outline: 2px solid var(--r2-blue); outline-offset: 1px; } +.popularity-refresh-v2 { min-height: 34px; padding: 0 11px; border-radius: 7px; } +.popularity-refresh-v2 .lucide { width: 15px; height: 15px; } + +.popularity-glance-v2 { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +.popularity-glance-v2 article { + min-width: 0; + min-height: 88px; + display: grid; + align-content: center; + gap: 5px; + position: relative; + overflow: hidden; + padding: 12px 15px; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} + +.popularity-glance-v2 article::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: #c7d8fb; +} + +.popularity-glance-v2 article:nth-child(2)::before { background: #b7ddcf; } +.popularity-glance-v2 article.consensus::before { background: #e6c773; } +.popularity-glance-v2 article > span { color: var(--r2-sub); font-size: 11px; } +.popularity-glance-v2 article > strong { + overflow: hidden; + color: var(--r2-ink); + font-size: 14px; + font-weight: 750; + text-overflow: ellipsis; + white-space: nowrap; +} +.popularity-glance-v2 article.consensus > strong { color: var(--r2-amber); font-size: 18px; } +.popularity-glance-v2 article > small { overflow: hidden; color: var(--r2-faint); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } + +.popularity-table-card-v2 { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} + +.popularity-table-head-v2 { + min-height: 52px; + flex: 0 0 auto; + justify-content: space-between; + gap: 16px; + padding: 8px 13px; + border-bottom: 1px solid var(--r2-line-soft); +} + +.popularity-table-head-v2 > div { min-width: 0; gap: 8px; } +.popularity-table-head-v2 h3 { margin: 0; color: var(--r2-ink); font-size: 14px; font-weight: 750; white-space: nowrap; } +.popularity-table-head-v2 > div > span { overflow: hidden; color: var(--r2-faint); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } +.popularity-search-v2 { + width: 230px; + height: 33px; + flex: 0 0 auto; + gap: 7px; + padding: 0 10px; + border: 1px solid #d8dde5; + border-radius: 7px; + color: var(--r2-faint); + transition: border-color 160ms ease, box-shadow 160ms ease; +} + +.popularity-search-v2:focus-within { border-color: #96b5f2; box-shadow: 0 0 0 3px rgba(37, 99, 235, .09); } +.popularity-search-v2 .lucide { width: 15px; height: 15px; } +.popularity-search-v2 input { min-width: 0; width: 100%; height: 100%; padding: 0; border: 0; outline: 0; background: transparent; color: var(--r2-ink); font: inherit; } +.popularity-search-v2 input::placeholder { color: var(--r2-faint); } + +.popularity-table-frame-v2 { + min-width: 0; + min-height: 0; + flex: 1 1 auto; + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.popularity-table-v2 { min-width: 930px; table-layout: fixed; } +.popularity-table-v2 thead th { + position: sticky; + top: 0; + z-index: 2; + height: 35px; + padding: 7px 10px; + border-bottom-color: var(--r2-line); + background: #fafbfc; + color: var(--r2-sub); + font-size: 10.5px; +} + +.popularity-table-v2 thead th:nth-child(1) { width: 78px; } +.popularity-table-v2 thead th:nth-child(2) { width: 220px; } +.popularity-table-v2 thead th:nth-child(3), +.popularity-table-v2 thead th:nth-child(4) { width: 105px; } +.popularity-table-v2 thead th:nth-child(5), +.popularity-table-v2 thead th:nth-child(6), +.popularity-table-v2 thead th:nth-child(7) { width: 115px; } +.popularity-table-v2 tbody td { height: 43px; padding: 7px 10px; font-size: 11.5px; } +.popularity-table-v2 tbody tr { cursor: pointer; } +.popularity-table-v2 tbody tr:hover { background: #f7f9fc; } + +.popularity-rank-v2 { white-space: nowrap; } +.popularity-rank-v2 b { color: var(--r2-ink); font-size: 12px; font-weight: 750; } +.popularity-rank-v2 span { + display: inline-grid; + place-items: center; + width: 20px; + height: 20px; + margin-left: 6px; + border-radius: 5px; + background: var(--r2-up-soft); + color: var(--r2-up); + font-size: 9px; + font-weight: 700; +} + +.popularity-stock-v2 { min-width: 0; gap: 6px; } +.popularity-stock-v2 strong { overflow: hidden; color: var(--r2-ink); font-size: 12px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } +.popularity-stock-v2 .stock-code { flex: 0 0 auto; color: var(--r2-faint); font-size: 10.5px; } +.popularity-list-rank-v2 { color: #405573; } +.popularity-movement-v2 { font-weight: 650; } +.popularity-concepts-v2 { overflow: hidden; color: var(--r2-sub); text-overflow: ellipsis; white-space: nowrap; } +.popularity-source-tag-v2 { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 2px 7px; + border-radius: 5px; + background: #f3f4f6; + color: var(--r2-sub); + font-size: 10px; + white-space: nowrap; +} +.popularity-source-tag-v2.dual { background: var(--r2-amber-soft); color: var(--r2-amber); } + +@media (min-width: 721px) { + body[data-active-view="popularityView"] .app-main { display: flex; flex-direction: column; overflow: hidden; } + body[data-active-view="popularityView"] .overview-strip { flex: 0 0 auto; } + body[data-active-view="popularityView"] #popularityView.active-view { + min-height: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; + } + body[data-active-view="popularityView"] .popularity-page-head-v2, + body[data-active-view="popularityView"] .popularity-glance-v2 { flex: 0 0 auto; } + body[data-active-view="popularityView"] .popularity-table-card-v2 { flex: 1 1 auto; } +} + +@media (min-width: 721px) and (max-height: 900px) { + .redesigned-popularity-view { padding-top: 9px; padding-bottom: 10px; } + .popularity-page-head-v2 { min-height: 45px; margin-bottom: 7px; } + .popularity-glance-v2 { gap: 9px; margin-bottom: 9px; } + .popularity-glance-v2 article { min-height: 76px; padding-top: 8px; padding-bottom: 8px; } + .popularity-table-head-v2 { min-height: 46px; } + .popularity-table-v2 tbody td { height: 39px; padding-top: 5px; padding-bottom: 5px; } +} + +@media (max-width: 960px) { + .popularity-title-v2 { flex-wrap: wrap; } + .popularity-title-v2 > small { width: 100%; } + .popularity-glance-v2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .popularity-glance-v2 article.consensus { grid-column: 1 / -1; } +} + +@media (max-width: 720px) { + .redesigned-popularity-view { padding: 10px; } + .popularity-page-head-v2 { align-items: stretch; flex-direction: column; gap: 9px; } + .popularity-title-v2 { gap: 6px 8px; } + .popularity-title-v2 > span { width: calc(100% - 110px); } + .popularity-title-v2 > strong { order: 4; } + .popularity-title-v2 > small { order: 5; width: auto; } + .popularity-head-actions-v2 { width: 100%; } + .popularity-source-tabs-v2 { min-width: 0; flex: 1 1 auto; } + .popularity-source-tabs-v2 button { min-width: 0; flex: 1 1 auto; padding-inline: 6px; } + .popularity-refresh-v2 span { display: none; } + .popularity-glance-v2 { grid-template-columns: minmax(0, 1fr); gap: 8px; } + .popularity-glance-v2 article, + .popularity-glance-v2 article.consensus { min-height: 74px; grid-column: auto; } + .popularity-table-card-v2 { min-height: 520px; } + .popularity-table-head-v2 { align-items: stretch; flex-direction: column; gap: 7px; } + .popularity-table-head-v2 > div { align-items: flex-start; flex-direction: column; gap: 2px; } + .popularity-search-v2 { width: 100%; } + .popularity-table-frame-v2 { overflow-x: auto; } +} + +@media (prefers-reduced-motion: reduce) { + .popularity-search-v2 { transition: none; } +} + +/* Stage 14: Dragon-Tiger list. The compact prototype shell and empty state + wrap the existing card interaction and daily operation detail. */ +.redesigned-dragon-view { + width: min(100%, 2200px); + margin: 0 auto; + padding: 12px 16px 14px; +} + +.dragon-page-head-v2, +.dragon-title-v2, +.dragon-head-actions-v2, +.dragon-view-tabs-v2, +.dragon-filterbar-v2, +.dragon-filter-actions-v2, +.dragon-segments-v2, +.dragon-search-v2, +.dragon-stage-heading-v2, +.dragon-stage-heading-v2 > div, +.dragon-empty-actions-v2 { display: flex; align-items: center; } + +.dragon-page-head-v2 { + min-height: 52px; + justify-content: space-between; + gap: 18px; + margin-bottom: 10px; +} + +.dragon-title-v2 { min-width: 0; gap: 9px; } +.dragon-title-v2 h2 { margin: 0; color: var(--r2-ink); font-size: 19px; font-weight: 750; } +.dragon-title-v2 > span { color: var(--r2-sub); font-size: 12px; } +.dragon-title-v2 > strong { + padding: 4px 8px; + border-radius: 5px; + background: var(--r2-amber-soft); + color: var(--r2-amber); + font-size: 11px; + font-weight: 650; + white-space: nowrap; +} + +.dragon-head-actions-v2 { flex: 0 0 auto; gap: 8px; } +.dragon-view-tabs-v2 { + min-height: 34px; + padding: 3px; + border: 1px solid var(--r2-line); + border-radius: 8px; + background: #f4f5f7; +} + +.dragon-view-tabs-v2 button { + min-height: 27px; + padding: 0 13px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--r2-sub); + font-size: 11px; + cursor: pointer; +} + +.dragon-view-tabs-v2 button.active { + background: #fff; + color: var(--r2-ink); + font-weight: 700; + box-shadow: 0 1px 3px rgba(16, 24, 40, .09); +} +.dragon-view-tabs-v2 button:focus-visible { outline: 2px solid var(--r2-blue); outline-offset: 1px; } +.dragon-action-v2 { min-height: 34px; padding: 0 11px; border-radius: 7px; } +.dragon-action-v2 .lucide { width: 15px; height: 15px; } + +.dragon-daily-content-v2 { + min-width: 0; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.dragon-summary-v2 { + min-height: 58px; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-bottom: 10px; + overflow: hidden; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} + +.dragon-summary-v2 .dragon-metric { + min-height: 58px; + display: grid; + align-content: center; + gap: 3px; + padding: 8px 15px; + border-right: 1px solid var(--r2-line); +} +.dragon-summary-v2 .dragon-metric:last-child { border-right: 0; } +.dragon-summary-v2 .dragon-metric span { color: var(--r2-sub); font-size: 11px; } +.dragon-summary-v2 .dragon-metric strong { margin: 0; color: var(--r2-ink); font-size: 18px; font-weight: 750; font-variant-numeric: tabular-nums; } +.dragon-summary-v2 .dragon-metric strong.up { color: var(--r2-up); } +.dragon-summary-v2 .dragon-metric strong.down { color: var(--r2-down); } + +.dragon-filterbar-v2 { + min-height: 52px; + justify-content: space-between; + gap: 14px; + margin-bottom: 10px; + padding: 7px 12px; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} +.dragon-filter-copy-v2 { min-width: 0; } +.dragon-filter-copy-v2 h3 { margin: 0; color: var(--r2-ink); font-size: 13px; font-weight: 700; } +.dragon-filter-copy-v2 span { display: block; margin-top: 2px; color: var(--r2-faint); font-size: 10px; } +.dragon-filter-actions-v2 { min-width: 0; gap: 8px; } +.dragon-segments-v2 { + min-height: 32px; + padding: 3px; + border-radius: 7px; + background: #f3f4f6; +} +.dragon-filter-v2 { + min-height: 26px; + padding: 0 10px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--r2-sub); + font-size: 10.5px; + cursor: pointer; + white-space: nowrap; +} +.dragon-filter-v2:hover { color: var(--r2-ink); } +.dragon-filter-v2.active { background: #fff; color: var(--r2-blue); font-weight: 700; box-shadow: 0 1px 3px rgba(16, 24, 40, .08); } +.dragon-filter-v2:focus-visible { outline: 2px solid var(--r2-blue); outline-offset: 1px; } + +.dragon-search-v2 { + width: 230px; + height: 33px; + flex: 0 0 auto; + gap: 7px; + padding: 0 10px; + border: 1px solid #d8dde5; + border-radius: 7px; + color: var(--r2-faint); + transition: border-color 160ms ease, box-shadow 160ms ease; +} +.dragon-search-v2:focus-within { border-color: #96b5f2; box-shadow: 0 0 0 3px rgba(37, 99, 235, .09); } +.dragon-search-v2 .lucide { width: 15px; height: 15px; } +.dragon-search-v2 input { min-width: 0; width: 100%; height: 100%; padding: 0; border: 0; outline: 0; background: transparent; color: var(--r2-ink); font: inherit; } +.dragon-search-v2 input::placeholder { color: var(--r2-faint); } + +#dragonView .dragon-card-stage-v2 { + margin: 0 0 10px; + overflow: hidden; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #f8f9fb; + box-shadow: var(--r2-shadow); +} +.dragon-stage-heading-v2 { + min-height: 46px; + justify-content: space-between; + gap: 14px; + padding: 8px 13px; + border-bottom: 1px solid var(--r2-line-soft); + background: #fff; +} +.dragon-stage-heading-v2 > div { min-width: 0; gap: 8px; } +.dragon-stage-heading-v2 h3 { margin: 0; color: var(--r2-ink); font-size: 14px; font-weight: 700; } +.dragon-stage-heading-v2 span, +.dragon-stage-heading-v2 small { color: var(--r2-faint); font-size: 10px; } +.dragon-stage-heading-v2 small { white-space: nowrap; } +#dragonView .dragon-trader-list { height: 270px; min-height: 270px; } + +#dragonView .dragon-trader-detail-v2 { + min-height: 0; + margin: 0 0 10px; + overflow: hidden; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} +#dragonView .dragon-detail-header { min-height: 78px; padding: 10px 14px; border-bottom-color: var(--r2-line-soft); } +#dragonView .dragon-detail-header h3 { font-size: 17px; } +#dragonView .dragon-detail-header dl { border-color: var(--r2-line); border-radius: 7px; } +#dragonView .dragon-detail-header dl div { padding: 7px 10px; border-right-color: var(--r2-line); } +#dragonView .dragon-trader-detail .trader-operations { max-height: none; overflow: visible; border-top-color: var(--r2-line-soft); } +#dragonView .dragon-operation-table { min-width: 1180px; table-layout: fixed; } +#dragonView .dragon-operation-table .dragon-col-index { width: 44px; } +#dragonView .dragon-operation-table .dragon-col-code { width: 88px; } +#dragonView .dragon-operation-table .dragon-col-name { width: 104px; } +#dragonView .dragon-operation-table .dragon-col-direction { width: 72px; } +#dragonView .dragon-operation-table .dragon-col-number { width: 82px; } +#dragonView .dragon-operation-table .dragon-col-seat { width: 190px; } +#dragonView .dragon-operation-table .dragon-col-reason { width: auto; } +#dragonView .dragon-operation-table :is(th, td).row-number { padding-inline: 8px; text-align: center; } +#dragonView .dragon-operation-table td.stock-code { font-variant-numeric: tabular-nums; } +#dragonView .dragon-operation-table thead th { position: sticky; top: 0; z-index: 2; background: #fafbfc; } +#dragonView .dragon-operation-table tbody tr:hover { background: #f7f9fc; } + +#dragonView .dragon-unclassified-v2 { + margin-bottom: 10px; + overflow: hidden; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); +} +#dragonView .dragon-unclassified-v2 .unclassified-heading { border-bottom-color: var(--r2-line-soft); background: #fff; } + +.dragon-empty-state-v2 { + min-height: 430px; + display: grid; + place-items: center; + align-content: center; + gap: 9px; + padding: 42px 20px; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: #fff; + box-shadow: var(--r2-shadow); + text-align: center; +} +.dragon-empty-state-v2[hidden] { display: none; } +.dragon-empty-symbol-v2 { + width: 54px; + height: 54px; + display: grid; + place-items: center; + margin-bottom: 4px; + border-radius: 50%; + background: var(--r2-blue-soft); + color: var(--r2-blue); +} +.dragon-empty-symbol-v2 .lucide { width: 25px; height: 25px; } +.dragon-empty-state-v2 h3 { margin: 0; color: var(--r2-ink); font-size: 16px; font-weight: 750; } +.dragon-empty-state-v2 p { max-width: 540px; margin: 0; color: var(--r2-sub); font-size: 11.5px; line-height: 1.7; } +.dragon-empty-actions-v2 { gap: 8px; margin-top: 10px; } +.dragon-empty-actions-v2 .button { min-height: 34px; border-radius: 7px; } +.dragon-empty-actions-v2 .lucide { width: 15px; height: 15px; } + +@media (min-width: 721px) { + body[data-active-view="dragonView"] .app-main { display: flex; flex-direction: column; overflow: hidden; } + body[data-active-view="dragonView"] .overview-strip { flex: 0 0 auto; } + body[data-active-view="dragonView"] #dragonView.active-view { + min-height: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; + } + body[data-active-view="dragonView"] .dragon-page-head-v2 { flex: 0 0 auto; } + body[data-active-view="dragonView"] .dragon-daily-content-v2, + body[data-active-view="dragonView"] .dragon-empty-state-v2 { flex: 1 1 auto; } +} + +@media (min-width: 721px) and (max-height: 900px) { + .redesigned-dragon-view { padding-top: 9px; padding-bottom: 10px; } + .dragon-page-head-v2 { min-height: 45px; margin-bottom: 7px; } + .dragon-summary-v2 { min-height: 50px; margin-bottom: 8px; } + .dragon-summary-v2 .dragon-metric { min-height: 50px; padding-top: 5px; padding-bottom: 5px; } + .dragon-filterbar-v2 { min-height: 46px; margin-bottom: 8px; padding-top: 5px; padding-bottom: 5px; } + #dragonView .dragon-card-stage-v2 { margin-bottom: 8px; } + .dragon-stage-heading-v2 { min-height: 41px; } + #dragonView .dragon-trader-list { height: 225px; min-height: 225px; } + #dragonView .dragon-trader-card { top: 12px; height: 202px; padding: 12px 10px 10px; } + #dragonView .dragon-card-monogram { width: 52px; height: 52px; flex-basis: 52px; font-size: 15px; } + #dragonView .dragon-card-copy { margin-top: 6px; } + #dragonView .dragon-card-copy strong { font-size: 14px; } + #dragonView .dragon-card-copy q { min-height: 35px; margin-top: 4px; font-size: 10px; -webkit-line-clamp: 3; } + #dragonView .dragon-card-stats { padding-top: 6px; } + #dragonView .dragon-detail-header { min-height: 70px; } +} + +@media (max-width: 960px) { + .dragon-title-v2 { flex-wrap: wrap; } + .dragon-filterbar-v2 { align-items: stretch; flex-direction: column; } + .dragon-filter-actions-v2 { justify-content: space-between; } +} + +@media (max-width: 720px) { + .redesigned-dragon-view { padding: 10px; } + .dragon-page-head-v2 { align-items: stretch; flex-direction: column; gap: 9px; } + .dragon-title-v2 { gap: 6px 8px; } + .dragon-title-v2 > span { width: calc(100% - 82px); } + .dragon-title-v2 > strong { width: fit-content; } + .dragon-head-actions-v2 { width: 100%; } + .dragon-view-tabs-v2 { min-width: 0; flex: 1 1 auto; } + .dragon-view-tabs-v2 button { min-width: 0; flex: 1 1 auto; padding-inline: 6px; } + .dragon-action-v2 span { display: none; } + .dragon-daily-content-v2 { overflow: visible; } + .dragon-summary-v2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dragon-summary-v2 .dragon-metric:nth-child(2) { border-right: 0; } + .dragon-summary-v2 .dragon-metric:nth-child(-n + 2) { border-bottom: 1px solid var(--r2-line); } + .dragon-filter-actions-v2 { align-items: stretch; flex-direction: column; } + .dragon-segments-v2 { width: 100%; overflow-x: auto; } + .dragon-filter-v2 { flex: 1 0 auto; } + .dragon-search-v2 { width: 100%; } + .dragon-stage-heading-v2 { align-items: flex-start; } + .dragon-stage-heading-v2 > div { align-items: flex-start; flex-direction: column; gap: 2px; } + .dragon-stage-heading-v2 small { display: none; } + #dragonView .dragon-trader-list { height: 246px; min-height: 246px; } + #dragonView .dragon-detail-header { align-items: stretch; flex-direction: column; } + #dragonView .dragon-trader-detail .trader-operations { overflow-x: auto; } + .dragon-empty-state-v2 { min-height: 360px; padding-inline: 16px; } + .dragon-empty-actions-v2 { align-items: stretch; flex-direction: column; width: min(100%, 240px); } +} + +@media (prefers-reduced-motion: reduce) { + .dragon-search-v2 { transition: none; } +} + +/* Stage 15: screener rebuilt from the approved reference layout. */ +#screenerView { + --scr-blue: #2563eb; + --scr-blue-soft: #eff4ff; + --scr-red: #e04536; + --scr-red-soft: #fff0ed; + --scr-green: #16a34a; + --scr-amber: #b45309; + --scr-amber-soft: #fff8ec; + padding: 14px 16px 18px; +} + +#screenerView .member-gate { margin-bottom: 12px; } + +#screenerView .screener-page-bar { + min-height: 36px; + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 12px; +} + +#screenerView .screener-page-heading { + min-height: 36px; + flex: 1 1 auto; + margin: 0; + padding: 0; + border: 0; + background: transparent; +} + +#screenerView .screener-page-heading .section-title-group { + display: flex; + align-items: baseline; + gap: 11px; +} + +#screenerView .screener-page-heading h2 { + margin: 0; + color: var(--r2-ink); + font-size: 17px; + font-weight: 800; +} + +#screenerView .screener-page-heading .section-subtitle { + color: var(--r2-faint); + font-size: 12px; +} + +#screenerView .screener-mode-tabs { + min-height: 36px; + display: inline-flex; + flex: 0 0 auto; + gap: 3px; + margin: 0; + padding: 3px; + border: 1px solid var(--r2-line); + border-radius: 10px; + background: #fff; +} + +#screenerView .screener-mode-tabs button { + min-width: 112px; + min-height: 30px; + padding: 0 17px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--r2-sub); + font-size: 13px; + font-weight: 650; + box-shadow: none; +} + +#screenerView .screener-mode-tabs button::after { display: none; } + +#screenerView .screener-mode-tabs button:hover { + background: #f5f7fa; + color: var(--r2-ink); +} + +#screenerView .screener-mode-tabs button.active { + background: var(--scr-blue); + color: #fff; +} + +#screenerView .screener-mobile-tabs { display: none; } + +#screenerView .screener-strategy-view { + display: flex; + flex-direction: column; + gap: 12px; +} + +#screenerView .screener-stepper { + min-height: 58px; + display: flex; + align-items: center; + gap: 0; + margin: 0; + padding: 10px 18px; + border: 1px solid var(--r2-line); + border-radius: 10px; + background: #fff; + box-shadow: var(--r2-shadow); +} + +#screenerView .screener-step { + min-width: 0; + display: flex; + align-items: center; + flex: 0 0 auto; + gap: 9px; +} + +#screenerView .screener-step .step-marker { + width: 24px; + height: 24px; + flex: 0 0 24px; + display: grid; + place-items: center; + border-radius: 50%; + background: #e8ebf0; + color: var(--r2-faint); + font-size: 11px; + font-weight: 800; +} + +#screenerView .screener-step strong, +#screenerView .screener-step small { display: block; white-space: nowrap; } +#screenerView .screener-step strong { font-size: 12.5px; line-height: 1.35; } +#screenerView .screener-step small { margin-top: 1px; color: var(--r2-faint); font-size: 10.5px; line-height: 1.3; } + +#screenerView .screener-step[data-state="complete"] .step-marker { + background: var(--scr-green); + color: transparent; +} + +#screenerView .screener-step[data-state="complete"] .step-marker::after { + content: "\2713"; + color: #fff; + font-size: 13px; +} + +#screenerView .screener-step[data-state="current"] .step-marker { + background: var(--scr-blue); + color: #fff; +} + +#screenerView .step-line { + width: clamp(30px, 5vw, 68px); + height: 1px; + flex: 0 1 68px; + margin: 0 14px; + background: #d9dee7; +} + +#screenerView .step-line.complete { background: #86d5a0; } + +#screenerView .screener-overview-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, .97fr); + gap: 12px; +} + +#screenerView .screener-overview-card { + min-width: 0; + overflow: hidden; + padding: 0; + border: 1px solid var(--r2-line); + border-radius: 10px; + background: #fff; + box-shadow: var(--r2-shadow); +} + +#screenerView .screener-card-heading { + min-height: 43px; + display: flex; + align-items: center; + padding: 0 14px; + border-bottom: 1px solid var(--r2-line-soft); +} + +#screenerView .screener-card-heading h3 { + margin: 0; + color: var(--r2-ink); + font-size: 14px; + font-weight: 750; +} + +#screenerView .screener-card-heading h3 > span { + margin-right: 4px; + color: var(--scr-blue); + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +#screenerView .screener-soft-label { + margin-left: auto; + padding: 3px 7px; + border-radius: 5px; + background: #f4f6f8; + color: var(--r2-sub); + font-size: 10.5px; +} + +#screenerView .screener-regime-body { + min-height: 164px; + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + gap: 15px; + align-items: start; + padding: 14px 16px; +} + +#screenerView .regime-summary { + min-height: 68px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + padding: 9px 8px; + border: 1px solid #f3c9c3; + border-radius: 9px; + background: var(--scr-red-soft); + text-align: center; + align-self: start; +} + +#screenerView .regime-summary strong { + color: var(--scr-red); + font-size: 19px; + font-weight: 800; + line-height: 1.2; +} + +#screenerView .regime-summary span { + margin-top: 3px; + color: var(--r2-sub); + font-size: 10.5px; +} + +#screenerView .regime-reading { min-width: 0; } + +#screenerView .regime-temperature { + min-height: 0; + display: flex; + align-items: baseline; + justify-content: flex-start; + flex-direction: row; + gap: 5px; + padding: 0; + border: 0; + color: #374151; + font-size: 12px; + line-height: 1.6; +} + +#screenerView .regime-temperature strong { + color: var(--r2-ink); + font-size: 16px; + font-weight: 800; +} + +#screenerView .regime-temperature small { color: var(--scr-green); font-size: 11px; } + +#screenerView .regime-evidence-line { + min-height: 18px; + margin-top: 2px; + overflow: hidden; + color: #4b5563; + font-size: 11.5px; + line-height: 1.55; + text-overflow: ellipsis; + white-space: nowrap; +} + +#screenerView .regime-advice { + margin-top: 6px; + padding: 6px 9px; + border-radius: 6px; + background: var(--scr-amber-soft); + color: var(--scr-amber); + font-size: 11.5px; + line-height: 1.55; +} + +#screenerView .regime-advice::before { content: "\26A0 "; } + +#screenerView .regime-selector { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; + margin: 9px 0 0; + padding: 0; + border: 0; +} + +#screenerView .regime-option { + min-height: 27px; + padding: 3px 11px; + border: 1px solid var(--r2-line); + border-radius: 6px; + background: #fff; + color: var(--r2-sub); + font-size: 11.5px; +} + +#screenerView .regime-option:hover { border-color: #bbc8db; color: var(--r2-ink); } + +#screenerView .regime-option.active { + border-color: var(--scr-red); + background: var(--scr-red-soft); + color: var(--scr-red); + font-weight: 700; +} + +#screenerView .factor-data-status { + display: flex; + align-items: center; + gap: 5px; + margin-top: 7px; + padding: 0; + border: 0; + background: transparent; + color: var(--scr-green); + font-size: 10.5px; +} + +#screenerView .factor-data-status::before { content: "\2713"; font-weight: 800; } +#screenerView .factor-data-status span { color: var(--scr-green); } +#screenerView .factor-data-status strong { margin-left: auto; color: var(--r2-sub); font-size: 10px; } +#screenerView .factor-data-status small { max-width: 160px; overflow: hidden; color: var(--r2-faint); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; } + +#screenerView .screener-strategy-summary { + min-height: 164px; + display: flex; + flex-direction: column; + padding: 14px 16px; +} + +#screenerView .screener-strategy-title { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 7px; +} + +#screenerView .screener-strategy-title > strong { + color: var(--r2-ink); + font-size: 15px; + font-weight: 800; +} + +#screenerView #activeStrategyRegimes { display: inline-flex; flex-wrap: wrap; gap: 4px; } + +#screenerView #activeStrategyRegimes b { + padding: 2px 7px; + border-radius: 5px; + background: var(--scr-red-soft); + color: var(--scr-red); + font-size: 10px; + font-weight: 600; +} + +#screenerView #activeStrategyRegimes b.neutral { background: #f3f4f6; color: var(--r2-sub); } + +#screenerView .screener-strategy-summary > p { + flex: 1; + margin: 9px 0; + color: var(--r2-sub); + font-size: 11.5px; + line-height: 1.7; +} + +#screenerView .screener-strategy-actions { display: flex; gap: 7px; margin-top: auto; } + +#screenerView .screener-runbar { + min-height: 56px; + display: flex; + align-items: center; + gap: 10px; + padding: 9px 14px; + border: 1px solid var(--r2-line); + border-radius: 10px; + background: #fff; + box-shadow: var(--r2-shadow); +} + +#screenerView .screener-run-actions { display: flex; align-items: center; gap: 8px; } + +#screenerView .screener-runbar .button, +#screenerView .curated-card-actions button, +#screenerView .quant-screener-panel .button { + min-height: 32px; + border-radius: 6px; + font-size: 11.5px; +} + +#screenerView .screener-runbar .button.primary, +#screenerView .quant-screener-panel .button.primary { background: var(--scr-blue); } + +#screenerView .screener-pipeline-status { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 15px; + margin-left: auto; + color: var(--r2-sub); + font-size: 10.5px; +} + +#screenerView .screener-pipeline-status strong { color: var(--scr-green); font-weight: 600; } + +#screenerView .screener-results-view, +#screenerView .strategy-tracking-panel { + margin-top: 12px; + overflow: hidden; + padding: 0; + border: 1px solid var(--r2-line); + border-radius: 10px; + background: #fff; + box-shadow: var(--r2-shadow); +} + +#screenerView .screener-results-view .result-toolbar, +#screenerView .strategy-tracking-panel .result-toolbar { + min-height: 47px; + margin: 0; + padding: 0 14px; + border: 0; + background: #fff; +} + +#screenerView .result-toolbar h2 { font-size: 14px; font-weight: 800; } +#screenerView .result-toolbar .count-badge { border-radius: 5px; background: #f1f3f6; color: var(--r2-sub); font-size: 10.5px; } +#screenerView .result-toolbar .section-subtitle { margin-left: auto; color: var(--r2-faint); font-size: 10.5px; } + +#screenerView .screener-backtest-strip { + min-height: 57px; + display: flex; + align-items: center; + gap: 18px; + margin: 0; + padding: 8px 14px; + border: 0; + border-bottom: 1px solid #f0e6d7; + border-radius: 0; + background: #fffaf3; +} + +#screenerView .screener-backtest-strip > .lucide { width: 15px; color: var(--scr-amber); } +#screenerView .screener-backtest-strip .dragon-summary { display: flex; flex: 0 0 auto; gap: 22px; } +#screenerView .screener-backtest-strip .dragon-summary > div { min-width: 64px; padding: 0; border: 0; background: transparent; } +#screenerView .screener-backtest-strip .dragon-summary span { display: block; color: var(--r2-faint); font-size: 10px; } +#screenerView .screener-backtest-strip .dragon-summary strong { display: block; margin-top: 2px; color: var(--r2-ink); font-size: 14px; } +#screenerView .screener-backtest-strip > p { max-width: 470px; margin: 0 0 0 auto; color: var(--r2-faint); font-size: 10px; line-height: 1.55; } + +#screenerView .screener-result-frame, +#screenerView .tracking-table-frame { + min-height: 230px; + overflow: auto; + border: 0; + border-top: 1px solid var(--r2-line-soft); + border-radius: 0; +} + +#screenerView .tracking-table-frame { min-height: 132px; } + +#screenerView .data-table { font-size: 11.5px; } +#screenerView .data-table thead th { height: 39px; padding: 8px 11px; background: #f8fafc; color: var(--r2-sub); font-size: 10.5px; } +#screenerView .data-table tbody td { height: 45px; padding: 8px 11px; } +#screenerView .data-table .stock-name { font-weight: 750; } +#screenerView .data-table .reason-column { max-width: 250px; overflow: hidden; color: #5f6b7c; text-overflow: ellipsis; } +#screenerView .data-table .risk-cell { max-width: 155px; overflow: hidden; color: var(--scr-amber); text-overflow: ellipsis; } +#screenerView .probability-value strong, +#screenerView .probability-value small { display: block; } +#screenerView .probability-value small { color: var(--r2-faint); font-size: 9px; } + +#screenerView .tracking-summary { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + border-top: 1px solid var(--r2-line-soft); +} + +#screenerView .tracking-summary > div { padding: 8px 14px; border-right: 1px solid var(--r2-line-soft); } +#screenerView .tracking-summary > div:last-child { border-right: 0; } +#screenerView .tracking-summary span, +#screenerView .tracking-summary strong { display: block; } +#screenerView .tracking-summary span { color: var(--r2-faint); font-size: 9.5px; } +#screenerView .tracking-summary strong { margin-top: 2px; font-size: 13px; } + +/* Curated strategy workspace. */ +#screenerView .curated-screener-panel { display: block; } +#screenerView .curated-library-pane { padding: 0; border: 0; background: transparent; box-shadow: none; } + +#screenerView .curated-library-heading { + min-height: 58px; + display: flex; + align-items: center; + padding: 9px 14px; + border: 1px solid var(--r2-line); + border-radius: 10px 10px 0 0; + background: #fff; +} + +#screenerView .curated-library-heading > div > span { color: var(--scr-blue); font-size: 10px; font-weight: 700; } +#screenerView .curated-library-heading h3 { display: inline; margin: 0 9px 0 0; font-size: 14px; } +#screenerView .curated-library-heading p { display: inline; margin: 0; color: var(--r2-faint); font-size: 10.5px; } +#screenerView .curated-library-heading > strong { margin-left: auto; color: var(--r2-sub); font-size: 11px; } + +#screenerView .curated-library-controls { + min-height: 50px; + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 12px; + padding: 8px 14px; + border: 1px solid var(--r2-line); + border-top: 0; + border-radius: 0 0 10px 10px; + background: #fff; +} + +#screenerView .curated-search { + width: 240px; + min-height: 32px; + display: flex; + align-items: center; + gap: 7px; + padding: 0 9px; + border: 1px solid var(--r2-line); + border-radius: 7px; + background: #fff; +} + +#screenerView .curated-search .lucide { width: 14px; color: var(--r2-faint); } +#screenerView .curated-search input { min-width: 0; flex: 1; border: 0; outline: 0; font-size: 11.5px; } +#screenerView .curated-category-filters { display: flex; gap: 5px; overflow-x: auto; } +#screenerView .curated-category-filters button { min-height: 28px; padding: 0 10px; border: 1px solid var(--r2-line); border-radius: 6px; background: #fff; color: var(--r2-sub); font-size: 10.5px; white-space: nowrap; } +#screenerView .curated-category-filters button.active { border-color: var(--scr-blue); background: var(--scr-blue-soft); color: var(--scr-blue); font-weight: 700; } + +#screenerView .curated-strategy-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + gap: 12px; + padding: 0; +} + +#screenerView .curated-strategy-card { + min-height: 158px; + display: flex; + flex-direction: column; + gap: 8px; + padding: 13px 15px; + border: 1px solid var(--r2-line); + border-radius: 10px; + background: #fff; + box-shadow: var(--r2-shadow); + transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease; +} + +#screenerView .curated-strategy-card:hover { + border-color: #b7c9ee; + transform: translateY(-2px); + box-shadow: 0 5px 16px rgba(37, 99, 235, .08); +} + +#screenerView .curated-strategy-card.active { border-color: #9cb5ec; background: #fbfdff; box-shadow: inset 0 3px var(--scr-blue), var(--r2-shadow); } +#screenerView .curated-card-head { display: grid; grid-template-columns: 27px minmax(0, 1fr) auto; align-items: center; gap: 8px; } +#screenerView .curated-strategy-rank { width: 27px; height: 27px; display: grid; place-items: center; border-radius: 5px; background: #f1f3f6; color: var(--r2-sub); font-size: 9.5px; font-style: normal; } +#screenerView .curated-card-head strong, +#screenerView .curated-card-head small { display: block; } +#screenerView .curated-card-head strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } +#screenerView .curated-card-head small { margin-top: 2px; color: var(--r2-faint); font-size: 9.5px; } +#screenerView .curated-card-tags { display: flex; flex-wrap: wrap; gap: 4px; } +#screenerView .curated-card-tags em { padding: 2px 6px; border-radius: 4px; background: var(--scr-blue-soft); color: var(--scr-blue); font-size: 9px; font-style: normal; } +#screenerView .curated-card-tags em:nth-child(2) { background: #f3f4f6; color: var(--r2-sub); } +#screenerView .curated-card-tags em:nth-child(3) { background: var(--scr-amber-soft); color: var(--scr-amber); } +#screenerView .curated-card-description { flex: 1; color: var(--r2-sub); font-size: 10.5px; line-height: 1.65; } +#screenerView .curated-card-foot { min-height: 35px; display: flex; align-items: center; gap: 7px; padding-top: 7px; border-top: 1px solid var(--r2-line-soft); } +#screenerView .curated-card-foot small { color: var(--r2-faint); font-size: 9.5px; } +#screenerView .curated-card-actions { display: flex; gap: 5px; margin-left: auto; } + +/* Quant workspace. */ +#screenerView .quant-screener-panel { + display: grid; + grid-template-columns: minmax(440px, .9fr) minmax(460px, 1.1fr); + gap: 12px; + align-items: start; +} + +#screenerView .quant-builder-pane, +#screenerView .quant-summary-pane { + min-width: 0; + overflow: hidden; + padding: 0; + border: 1px solid var(--r2-line); + border-radius: 10px; + background: #fff; + box-shadow: var(--r2-shadow); +} + +#screenerView .quant-panel-heading, +#screenerView .quant-summary-pane > header { + min-height: 52px; + display: flex; + align-items: center; + padding: 8px 14px; + border-bottom: 1px solid var(--r2-line-soft); + background: #fff; +} + +#screenerView .quant-panel-heading > div > span, +#screenerView .quant-summary-pane > header > span { display: block; color: var(--scr-blue); font-size: 9.5px; font-weight: 700; } +#screenerView .quant-panel-heading h3, +#screenerView .quant-summary-pane > header h3 { margin: 1px 0 0; font-size: 14px; } +#screenerView .quant-panel-heading .button { margin-left: auto; } + +#screenerView .quant-universe-section, +#screenerView .quant-rule-section { padding: 12px 14px; } +#screenerView .quant-universe-section { border-bottom: 1px solid var(--r2-line-soft); } +#screenerView .mini-section-heading { display: flex; align-items: center; gap: 8px; } +#screenerView .mini-section-heading h4 { margin: 0; font-size: 11.5px; } +#screenerView .mini-section-heading p { margin: 2px 0 0; color: var(--r2-faint); font-size: 9.5px; } +#screenerView .mini-section-heading .icon-text-button { margin-left: auto; } + +#screenerView .quant-universe-grid { + display: grid; + grid-template-columns: repeat(3, minmax(90px, 1fr)); + gap: 8px; + margin-top: 10px; + padding: 0; + border: 0; + background: transparent; +} + +#screenerView .quant-universe-grid .form-field span { font-size: 9.5px; } +#screenerView .quant-universe-grid input { min-height: 32px; } +#screenerView .quant-st-toggle { grid-column: 1 / -1; min-height: 32px; padding: 0 9px; border-radius: 6px; background: #f7f8fa; } +#screenerView .quant-rule-rows { display: flex; flex-direction: column; gap: 7px; margin-top: 10px; } + +#screenerView .quant-rule-row { + min-height: 47px; + display: grid; + align-items: center; + gap: 8px; + padding: 7px 9px; + border: 1px solid var(--r2-line-soft); + border-radius: 7px; + background: #fafbfc; +} + +#screenerView .quant-score-row { grid-template-columns: minmax(112px, .8fr) minmax(160px, 1.2fr) minmax(132px, .9fr) 28px; } +#screenerView .quant-filter-row { grid-template-columns: minmax(130px, 1fr) 74px minmax(100px, .8fr) 28px; } +#screenerView .quant-rule-row select, +#screenerView .quant-rule-row input[type="text"] { min-width: 0; min-height: 30px; border: 1px solid #dce1e8; border-radius: 6px; background: #fff; font-size: 10.5px; } + +#screenerView .quant-weight-control { display: grid; grid-template-columns: minmax(0, 1fr) 36px; align-items: center; gap: 7px; } +#screenerView .quant-weight-control input[type="range"] { width: 100%; height: 5px; accent-color: var(--scr-blue); } +#screenerView .quant-weight-control output { color: var(--r2-ink); font-size: 11px; font-weight: 700; text-align: right; } +#screenerView .quant-direction-control { display: grid; grid-template-columns: 1fr 1fr; padding: 2px; border: 1px solid var(--r2-line); border-radius: 6px; background: #fff; } +#screenerView .quant-direction-control button { min-height: 25px; padding: 0 5px; border: 0; border-radius: 4px; background: transparent; color: var(--r2-faint); font-size: 9px; } +#screenerView .quant-direction-control button.active { background: var(--scr-blue-soft); color: var(--scr-blue); font-weight: 700; } +#screenerView .quant-remove-button { width: 27px; height: 27px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 5px; background: transparent; color: var(--r2-faint); } +#screenerView .quant-remove-button:hover { background: var(--scr-red-soft); color: var(--scr-red); } +#screenerView .quant-remove-button .lucide { width: 13px; } + +#screenerView .quant-filter-section { min-height: 158px; border-bottom: 1px solid var(--r2-line-soft); } +#screenerView .quant-execution-heading { display: flex; align-items: center; padding: 12px 14px 0; } +#screenerView .quant-execution-heading span { font-size: 11.5px; font-weight: 700; } +#screenerView .quant-execution-heading small { margin-left: auto; color: var(--r2-faint); font-size: 9.5px; } +#screenerView .quant-formula-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 0; margin: 8px 14px 10px; overflow: hidden; border: 1px solid var(--r2-line-soft); border-radius: 7px; } +#screenerView .quant-summary-block { min-width: 0; padding: 8px 10px; border-right: 1px solid var(--r2-line-soft); border-bottom: 1px solid var(--r2-line-soft); } +#screenerView .quant-summary-block:nth-child(2n) { border-right: 0; } +#screenerView .quant-summary-block:nth-last-child(-n + 2) { border-bottom: 0; } +#screenerView .quant-summary-block span, +#screenerView .quant-summary-block strong { display: block; } +#screenerView .quant-summary-block span { color: var(--r2-faint); font-size: 9px; } +#screenerView .quant-summary-block strong { margin-top: 2px; overflow: hidden; font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } + +#screenerView .quant-weight-status { display: grid; grid-template-columns: auto 42px minmax(100px, 1fr); align-items: center; gap: 8px; margin: 0 14px 10px; font-size: 10.5px; } +#screenerView .quant-weight-status strong { text-align: right; } +#screenerView .quant-weight-status > div { height: 6px; overflow: hidden; border-radius: 3px; background: #e8ebf0; } +#screenerView .quant-weight-status i { display: block; height: 100%; border-radius: inherit; } +#screenerView .quant-summary-pane > .checkbox-control { margin: 0 14px 7px; } +#screenerView .quant-summary-pane > .button { width: calc(100% - 28px); margin: 6px 14px 0; } +#screenerView .quant-validation-message { margin: 9px 14px 12px; color: var(--scr-green); font-size: 9.5px; } +#screenerView .quant-validation-message.error { color: var(--scr-amber); } + +#screenerView .curated-detail-dialog { width: min(850px, calc(100vw - 30px)); max-height: min(760px, calc(100dvh - 30px)); padding: 0; overflow: visible; border: 0; border-radius: 11px; background: transparent; } +#screenerView .curated-detail-dialog::backdrop, +#screenerView .strategy-drawer::backdrop { background: rgba(20, 29, 44, .38); backdrop-filter: blur(3px); } +#screenerView .curated-detail-pane { position: relative; max-height: min(760px, calc(100dvh - 30px)); overflow-y: auto; border-radius: 11px; background: #fff; } + +@media (max-width: 1180px) { + #screenerView .screener-step small { max-width: 120px; overflow: hidden; text-overflow: ellipsis; } + #screenerView .screener-overview-grid, + #screenerView .quant-screener-panel { grid-template-columns: 1fr; } + #screenerView .curated-strategy-list { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} + +@media (max-width: 820px) { + #screenerView { padding: 10px; } + #screenerView .screener-page-bar { align-items: stretch; flex-direction: column; gap: 6px; } + #screenerView .screener-mode-tabs { width: 100%; } + #screenerView .screener-mode-tabs button { min-width: 0; flex: 1 1 0; padding-inline: 5px; } + #screenerView .screener-stepper { overflow-x: auto; padding-inline: 12px; } + #screenerView .screener-step { min-width: 112px; } + #screenerView .step-line { width: 24px; flex-basis: 24px; margin-inline: 7px; } + #screenerView .screener-runbar { align-items: stretch; flex-direction: column; } + #screenerView .screener-run-actions { display: grid; grid-template-columns: 1fr 1fr; } + #screenerView .screener-pipeline-status { margin-left: 0; } + #screenerView .screener-backtest-strip { align-items: flex-start; flex-wrap: wrap; } + #screenerView .screener-backtest-strip > p { max-width: none; margin-left: 0; } + #screenerView .curated-strategy-list { grid-template-columns: 1fr 1fr; } + #screenerView .curated-library-controls { align-items: stretch; flex-direction: column; } + #screenerView .curated-search { width: 100%; } +} + +@media (max-width: 560px) { + #screenerView .screener-mobile-tabs { display: grid; } + #screenerView .screener-regime-body { grid-template-columns: 82px minmax(0, 1fr); gap: 10px; padding: 12px; } + #screenerView .regime-selector { gap: 4px; } + #screenerView .regime-option { flex: 1 1 27%; padding-inline: 5px; } + #screenerView .factor-data-status strong, + #screenerView .factor-data-status small { display: none; } + #screenerView .screener-run-actions { grid-template-columns: 1fr; } + #screenerView .screener-backtest-strip .dragon-summary { display: grid; grid-template-columns: 1fr 1fr; width: calc(100% - 32px); gap: 9px; } + #screenerView .result-toolbar { align-items: flex-start; flex-direction: column; padding-block: 9px !important; } + #screenerView .result-toolbar .section-subtitle { margin-left: 0; } + #screenerView .tracking-summary { grid-template-columns: repeat(2, 1fr); } + #screenerView .tracking-summary > div { border-bottom: 1px solid var(--r2-line-soft); } + #screenerView .curated-strategy-list { grid-template-columns: 1fr; } + #screenerView .curated-library-heading { align-items: flex-start; } + #screenerView .curated-library-heading p { display: block; margin-top: 2px; } + #screenerView .quant-universe-grid, + #screenerView .quant-formula-summary { grid-template-columns: 1fr; } + #screenerView .quant-st-toggle { grid-column: auto; } + #screenerView .quant-summary-block { border-right: 0; } + #screenerView .quant-summary-block:nth-last-child(-n + 2) { border-bottom: 1px solid var(--r2-line-soft); } + #screenerView .quant-summary-block:last-child { border-bottom: 0; } + #screenerView .quant-score-row, + #screenerView .quant-filter-row { grid-template-columns: 1fr; } + #screenerView .quant-remove-button { justify-self: end; } +} + +@media (prefers-reduced-motion: reduce) { + #screenerView .curated-strategy-card { transition: none; } +} + +/* Stage 15 refinement: match the approved proportions and reading order. */ +#screenerView .screener-page-bar { flex-wrap: nowrap; } + +#screenerView .screener-tracking-entry { + min-height: 36px; + flex: 0 0 auto; + gap: 6px; + border-color: var(--r2-line); + border-radius: 8px; + background: #fff; + color: var(--r2-sub); + font-size: 11.5px; +} + +#screenerView .screener-tracking-entry:hover { border-color: #b9c8e4; color: var(--scr-blue); } +#screenerView .screener-tracking-entry .lucide { width: 14px; } + +#screenerView .screener-stepper { min-height: 52px; padding-block: 8px; } +#screenerView .screener-overview-grid { grid-template-columns: 1fr 1fr; } + +#screenerView .screener-regime-body { + min-height: 142px; + grid-template-columns: 84px minmax(0, 1fr); + gap: 13px; + padding: 12px 14px; +} + +#screenerView .regime-summary { min-height: 60px; padding: 8px 6px; } +#screenerView .regime-summary strong { font-size: 17px; } + +#screenerView .regime-market-line { + min-width: 0; + display: flex; + align-items: baseline; + gap: 7px; + line-height: 1.55; +} + +#screenerView .regime-temperature { flex: 0 0 auto; white-space: nowrap; } +#screenerView .regime-temperature strong { font-size: 14px; } +#screenerView .regime-evidence-line { + min-width: 0; + min-height: 0; + margin: 0; + flex: 1; + font-size: 11px; +} + +#screenerView .regime-advice { margin-top: 5px; padding-block: 5px; } + +#screenerView .regime-control-line { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px 8px; + margin-top: 8px; +} + +#screenerView .regime-control-line .regime-selector { margin: 0; } +#screenerView .regime-control-line .factor-data-status { margin: 0; white-space: nowrap; } +#screenerView .regime-control-line .factor-data-status strong, +#screenerView .regime-control-line .factor-data-status small { display: none; } +#screenerView .regime-option { min-height: 25px; padding: 2px 9px; } + +#screenerView .screener-strategy-summary { min-height: 142px; padding: 12px 14px; } +#screenerView .screener-strategy-summary > p { margin: 7px 0 9px; line-height: 1.65; } + +#screenerView .screener-backtest-strip { + min-height: 54px; + max-height: 54px; + flex-wrap: nowrap; + padding-block: 7px; + overflow: hidden; +} + +#screenerView .screener-backtest-strip .dragon-summary { gap: 18px; } +#screenerView .screener-backtest-strip .dragon-summary > div { min-width: 58px; } +#screenerView .screener-backtest-strip .dragon-metric { + min-height: 0; + height: auto; + padding: 0; +} +#screenerView .screener-backtest-strip .dragon-summary strong { font-size: 13px; } + +/* Quant controls follow the compact left-rail prototype. */ +#screenerView .quant-screener-panel { + grid-template-columns: 340px minmax(0, 1fr); + gap: 10px; +} + +#screenerView .quant-intro-band { + min-height: 38px; + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 9px; + padding: 7px 12px; + border: 1px solid var(--r2-line); + border-radius: 9px; + background: #fff; +} + +#screenerView .quant-intro-band strong { font-size: 11.5px; } +#screenerView .quant-intro-band span { color: var(--r2-faint); font-size: 10.5px; } + +#screenerView .quant-builder-pane, +#screenerView .quant-summary-pane { border-radius: 9px; } + +#screenerView .quant-builder-pane .quant-rule-section { padding: 0; } +#screenerView .quant-builder-pane .mini-section-heading { min-height: 43px; padding: 7px 12px; border-bottom: 1px solid var(--r2-line-soft); } +#screenerView .quant-builder-pane .quant-rule-rows { gap: 0; margin: 0; } + +#screenerView .quant-score-row { + min-height: 54px; + grid-template-columns: minmax(112px, 1fr) minmax(112px, 1.25fr) 24px; + gap: 8px; + padding: 7px 10px; + border: 0; + border-bottom: 1px solid var(--r2-line-soft); + border-radius: 0; + background: #fff; +} + +#screenerView .quant-factor-identity { + min-width: 0; + display: grid; + grid-template-columns: 15px minmax(0, 1fr); + align-items: center; + gap: 6px; +} + +#screenerView .quant-factor-identity > i { + width: 13px; + height: 13px; + display: grid; + place-items: center; + border-radius: 3px; + background: var(--scr-blue); + color: #fff; + font-size: 8px; + font-style: normal; +} + +#screenerView .quant-factor-identity > span { min-width: 0; } +#screenerView .quant-factor-identity select { + width: 100%; + min-height: 22px; + padding: 0 18px 0 0; + overflow: hidden; + border: 0; + background-color: transparent; + color: var(--r2-ink); + font-size: 11px; + font-weight: 650; + text-overflow: ellipsis; +} + +#screenerView .quant-factor-identity button { + min-height: 0; + display: block; + padding: 0; + border: 0; + background: transparent; + color: var(--r2-faint); + font-size: 8.5px; + line-height: 1.3; + text-align: left; +} + +#screenerView .quant-factor-identity button:hover { color: var(--scr-blue); } +#screenerView .quant-weight-control { grid-template-columns: minmax(0, 1fr) 30px; gap: 5px; } +#screenerView .quant-weight-control output { font-size: 10px; } +#screenerView .quant-score-row .quant-remove-button { width: 22px; height: 22px; } + +#screenerView .quant-builder-pane > .quant-weight-status { + min-height: 35px; + grid-template-columns: auto minmax(0, 1fr) 34px; + margin: 0; + padding: 8px 10px; + border-top: 0; + background: #f8fafc; +} + +#screenerView .quant-summary-pane > .quant-universe-section { + padding: 10px 13px; + border-bottom: 1px solid var(--r2-line-soft); +} + +#screenerView .quant-summary-pane .quant-universe-grid { + grid-template-columns: repeat(3, minmax(100px, 1fr)) minmax(128px, .9fr); + align-items: end; + gap: 7px; +} + +#screenerView .quant-summary-pane .quant-st-toggle { + grid-column: auto; + min-height: 32px; + margin: 0; +} + +#screenerView .quant-filter-section { min-height: 0; padding: 10px 13px; } +#screenerView .quant-filter-section .quant-rule-rows { gap: 6px; margin-top: 8px; } +#screenerView .quant-filter-row { + min-height: 40px; + grid-template-columns: minmax(160px, 1.2fr) 72px minmax(150px, 1fr) 26px; + padding: 5px 8px; +} + +#screenerView .quant-execution-heading { padding: 10px 13px 0; } +#screenerView .quant-formula-summary { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 7px 13px 9px; +} + +#screenerView .quant-summary-block { border-bottom: 0; } +#screenerView .quant-summary-block:nth-child(2n) { border-right: 1px solid var(--r2-line-soft); } +#screenerView .quant-summary-block:last-child { border-right: 0; } + +#screenerView .quant-execution-actions { + display: grid; + grid-template-columns: auto minmax(150px, 1fr) auto; + align-items: center; + gap: 7px; + margin: 8px 13px 0; +} + +#screenerView .quant-execution-actions .checkbox-control { margin: 0; white-space: nowrap; } +#screenerView .quant-execution-actions .button { width: auto; margin: 0; } +#screenerView .quant-summary-pane > .quant-validation-message { margin: 8px 13px 10px; } + +#screenerView .screener-row-actions { display: inline-flex; align-items: center; gap: 5px; } +#screenerView .tracking-action { min-width: 54px; color: var(--scr-blue); } +#screenerView .tracking-action.tracked { color: var(--scr-green); } +#screenerView .screener-result-frame th:last-child, +#screenerView .screener-result-frame td:last-child { + position: sticky; + right: 0; + z-index: 2; + min-width: 116px; + background: #fff; + box-shadow: -7px 0 10px -10px rgba(31, 41, 55, .45); +} +#screenerView .screener-result-frame thead th:last-child { z-index: 3; background: #f8fafc; } +#screenerView .screener-result-frame tbody tr:hover td:last-child { background: #f8faff; } + +/* Tracking is an internal screener page, intentionally absent from the sidebar. */ +#screenerTrackingView { + padding: 14px 16px 18px; + background: var(--r2-bg); +} + +#screenerTrackingView .tracking-page-header { + min-height: 62px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + margin-bottom: 12px; + padding: 10px 14px; + border: 1px solid var(--r2-line); + border-radius: 10px; + background: #fff; + box-shadow: var(--r2-shadow); +} + +#screenerTrackingView .tracking-back-button { + min-height: 32px; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0 9px; + border: 1px solid var(--r2-line); + border-radius: 6px; + background: #fff; + color: var(--r2-sub); + font-size: 11px; +} + +#screenerTrackingView .tracking-back-button:hover { border-color: #b9c8e4; color: var(--scr-blue); } +#screenerTrackingView .tracking-back-button .lucide { width: 14px; } +#screenerTrackingView .tracking-page-header h2 { margin: 0; font-size: 16px; } +#screenerTrackingView .tracking-page-header p { margin: 3px 0 0; color: var(--r2-faint); font-size: 10.5px; } + +#screenerTrackingView .tracking-overview-card, +#screenerTrackingView .strategy-tracking-panel { + overflow: hidden; + border: 1px solid var(--r2-line); + border-radius: 10px; + background: #fff; + box-shadow: var(--r2-shadow); +} + +#screenerTrackingView .tracking-overview-card { display: grid; grid-template-columns: 150px minmax(0, 1fr); margin-bottom: 12px; } +#screenerTrackingView .tracking-overview-title { display: flex; justify-content: center; flex-direction: column; gap: 3px; padding: 13px 16px; border-right: 1px solid var(--r2-line-soft); } +#screenerTrackingView .tracking-overview-title span { color: var(--r2-sub); font-size: 10.5px; } +#screenerTrackingView .tracking-overview-title strong { font-size: 17px; } +#screenerTrackingView .tracking-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); } +#screenerTrackingView .tracking-summary > div { display: flex; justify-content: center; flex-direction: column; padding: 11px 15px; border-right: 1px solid var(--r2-line-soft); } +#screenerTrackingView .tracking-summary > div:last-child { border-right: 0; } +#screenerTrackingView .tracking-summary span, +#screenerTrackingView .tracking-summary strong { display: block; } +#screenerTrackingView .tracking-summary span { color: var(--r2-faint); font-size: 9.5px; } +#screenerTrackingView .tracking-summary strong { margin-top: 2px; font-size: 14px; } + +#screenerTrackingView .strategy-tracking-panel { padding: 0; } +#screenerTrackingView .result-toolbar { min-height: 47px; margin: 0; padding: 0 14px; border: 0; } +#screenerTrackingView .result-toolbar h2 { font-size: 14px; } +#screenerTrackingView .result-toolbar .section-subtitle { margin-left: auto; font-size: 10.5px; } +#screenerTrackingView .tracking-table-frame { min-height: 420px; overflow: auto; border: 0; border-top: 1px solid var(--r2-line-soft); border-radius: 0; } +#screenerTrackingView .data-table { font-size: 11.5px; } +#screenerTrackingView .data-table thead th { height: 39px; padding: 8px 11px; background: #f8fafc; font-size: 10.5px; } +#screenerTrackingView .data-table tbody td { height: 46px; padding: 8px 11px; } +#screenerTrackingView .stock-cell { display: flex; align-items: flex-start; flex-direction: column; gap: 2px; } +#screenerTrackingView .stock-cell small { color: var(--r2-faint); font-size: 9px; } +#screenerTrackingView .table-action.danger { color: var(--scr-red); } + +@media (max-width: 1180px) { + #screenerView .quant-screener-panel { grid-template-columns: 320px minmax(0, 1fr); } + #screenerView .quant-summary-pane .quant-universe-grid { grid-template-columns: repeat(2, minmax(100px, 1fr)); } + #screenerView .quant-formula-summary { grid-template-columns: 1fr 1fr; } + #screenerView .quant-summary-block { border-bottom: 1px solid var(--r2-line-soft); } + #screenerView .quant-summary-block:nth-last-child(-n + 2) { border-bottom: 0; } +} + +@media (max-width: 900px) { + #screenerView .screener-page-bar { flex-wrap: wrap; } + #screenerView .screener-page-heading { width: 100%; flex-basis: 100%; } + #screenerView .screener-mode-tabs { flex: 1 1 auto; } + #screenerView .screener-overview-grid, + #screenerView .quant-screener-panel { grid-template-columns: 1fr; } + #screenerView .quant-intro-band { grid-column: auto; } + #screenerTrackingView .tracking-overview-card { grid-template-columns: 1fr; } + #screenerTrackingView .tracking-overview-title { border-right: 0; border-bottom: 1px solid var(--r2-line-soft); } +} + +@media (max-width: 620px) { + #screenerView .screener-tracking-entry { flex: 0 0 40px; width: 40px; padding: 0; font-size: 0; } + #screenerView .screener-tracking-entry .lucide { width: 15px; } + #screenerView .regime-market-line { align-items: flex-start; flex-direction: column; gap: 1px; } + #screenerView .regime-evidence-line { width: 100%; white-space: normal; } + #screenerView .screener-backtest-strip { max-height: none; flex-wrap: wrap; } + #screenerView .quant-summary-pane .quant-universe-grid, + #screenerView .quant-formula-summary, + #screenerView .quant-execution-actions { grid-template-columns: 1fr; } + #screenerView .quant-score-row, + #screenerView .quant-filter-row { grid-template-columns: 1fr; } + #screenerTrackingView { padding: 10px; } + #screenerTrackingView .tracking-page-header { grid-template-columns: 1fr auto; } + #screenerTrackingView .tracking-page-header > div { grid-column: 1 / -1; grid-row: 1; } + #screenerTrackingView .tracking-back-button { grid-column: 1; grid-row: 2; } + #screenerTrackingView #refreshTrackingButton { grid-column: 2; grid-row: 2; } + #screenerTrackingView .tracking-summary { grid-template-columns: repeat(2, 1fr); } +} + +/* Stage 15 visual correction: transfer the approved screener prototype proportions. */ +#screenerView .screener-step .step-marker { position: relative; overflow: visible; } +#screenerView .screener-step[data-state="complete"] .step-marker { font-size: 0; } +#screenerView .screener-step[data-state="complete"] .step-marker::after { + position: absolute; + inset: 0; + display: grid; + place-items: center; + line-height: 1; +} + +#screenerView .regime-market-line { display: block; line-height: 1.7; } +#screenerView .regime-evidence-line { + min-height: 22px; + margin: 0; + color: #374151; + font-size: 12.5px; + line-height: 1.7; +} + +#screenerView .quant-screener-panel { + grid-template-columns: 400px minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +#screenerView .quant-builder-pane, +#screenerView .quant-summary-pane { + overflow: hidden; + border: 1px solid var(--r2-line); + border-radius: 10px; + background: #fff; + box-shadow: var(--r2-shadow); +} + +#screenerView .quant-right-stack { + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +#screenerView .quant-right-stack > .quant-summary-pane { order: 0; } +#screenerView .quant-right-stack > [data-screener-results-slot="quant"] { min-width: 0; order: 1; } + +#screenerView .quant-panel-heading, +#screenerView .quant-summary-pane > header { + min-height: 45px; + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--r2-line-soft); +} + +#screenerView .quant-panel-heading h3, +#screenerView .quant-summary-pane > header h3 { + margin: 0; + color: var(--r2-ink); + font-size: 14px; + font-weight: 700; +} + +#screenerView .quant-panel-heading > div, +#screenerView .quant-summary-pane > header > .button { margin-left: auto; } +#screenerView .quant-panel-heading .button, +#screenerView .quant-summary-pane > header .button { + min-height: 28px; + padding: 4px 9px; + border-radius: 7px; + background: #fff; + font-size: 12px; +} + +#screenerView .quant-panel-heading .button.ghost, +#screenerView .quant-summary-pane > header .button.ghost { border-color: transparent; color: var(--scr-blue); } +#screenerView .quant-panel-heading .button .lucide, +#screenerView .quant-summary-pane > header .button .lucide { width: 13px; } + +#screenerView .quant-builder-pane > .quant-rule-rows { gap: 0; margin: 0; } +#screenerView .quant-score-row { + min-height: 58px; + grid-template-columns: minmax(118px, 1fr) minmax(135px, 1.25fr) 24px; + gap: 10px; + padding: 9px 14px; + border: 0; + border-bottom: 1px solid var(--r2-line-soft); + border-radius: 0; + background: #fff; +} + +#screenerView .quant-factor-identity { + grid-template-columns: 15px minmax(0, 1fr); + gap: 10px; +} + +#screenerView .quant-factor-identity > i { width: 14px; height: 14px; font-size: 8px; } +#screenerView .quant-factor-identity select { font-size: 12.5px; font-weight: 600; } +#screenerView .quant-factor-identity button { margin-top: 2px; font-size: 10.5px; } +#screenerView .quant-weight-control { grid-template-columns: minmax(0, 1fr) 38px; gap: 8px; } +#screenerView .quant-weight-control output { font-size: 12px; font-variant-numeric: tabular-nums; } + +#screenerView .quant-builder-pane > .quant-weight-status { + min-height: 40px; + grid-template-columns: auto minmax(0, 1fr) 42px; + gap: 8px; + margin: 0; + padding: 10px 14px; + border: 0; + background: #f8fafc; + font-size: 12px; +} + +#screenerView .quant-filter-body { padding: 5px 16px 10px; } +#screenerView .quant-summary-pane .quant-universe-grid { + display: flex; + align-items: center; + gap: 8px 12px; + padding: 7px 0; + flex-wrap: wrap; +} + +#screenerView .quant-summary-pane .quant-universe-grid > strong { font-size: 12.5px; } +#screenerView .quant-summary-pane .quant-universe-grid .form-field { + min-width: 0; + display: inline-flex; + align-items: center; + flex-direction: row; + gap: 5px; +} + +#screenerView .quant-summary-pane .quant-universe-grid .form-field span { color: var(--r2-sub); font-size: 12px; } +#screenerView .quant-summary-pane .quant-universe-grid .form-field small { color: var(--r2-sub); font-size: 11px; } +#screenerView .quant-summary-pane .quant-universe-grid input[type="number"] { + width: 66px; + min-height: 30px; + padding: 4px 8px; + border: 1px solid var(--r2-line); + border-radius: 6px; + font-size: 12px; +} + +#screenerView .quant-summary-pane .quant-st-toggle { + min-height: 30px; + padding: 0; + background: transparent; + font-size: 12px; +} + +#screenerView .quant-summary-pane .quant-rule-rows { gap: 0; margin: 0; } +#screenerView .quant-filter-row { + min-height: 42px; + grid-template-columns: minmax(150px, 1fr) 72px 96px 26px; + gap: 8px; + padding: 6px 0; + border: 0; + border-top: 1px solid var(--r2-line-soft); + border-radius: 0; + background: #fff; +} + +#screenerView .quant-filter-row select, +#screenerView .quant-filter-row input[type="text"] { min-height: 30px; font-size: 12px; } + +#screenerView .quant-execution-actions { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 8px 0 0; + border-top: 1px solid var(--r2-line-soft); + flex-wrap: wrap; +} + +#screenerView .quant-execution-actions .button { + width: auto; + min-height: 32px; + margin: 0; + padding: 6px 13px; + font-size: 12.5px; +} + +#screenerView .quant-execution-actions .checkbox-control { margin: 0 0 0 4px; white-space: nowrap; } +#screenerView .quant-execution-actions > span { color: var(--r2-faint); font-size: 11px; } +#screenerView .quant-summary-pane > .quant-validation-message, +#screenerView .quant-filter-body > .quant-validation-message { margin: 7px 0 0; font-size: 10.5px; } +#screenerView .quant-right-stack .screener-results-view { margin: 0; } +#screenerView .quant-right-stack .screener-result-frame { min-height: 250px; } + +@media (max-width: 1180px) { + #screenerView .quant-screener-panel { grid-template-columns: 360px minmax(0, 1fr); } +} + +@media (max-width: 900px) { + #screenerView .quant-screener-panel { grid-template-columns: 1fr; } +} + +@media (max-width: 620px) { + #screenerView .quant-score-row, + #screenerView .quant-filter-row { grid-template-columns: 1fr; } + #screenerView .quant-remove-button { justify-self: end; } + #screenerView .quant-execution-actions { align-items: stretch; flex-direction: column; } + #screenerView .quant-execution-actions .button { width: 100%; justify-content: center; } +} + +/* Stage 15 spacing pass: denser phase cards and clearer secondary actions. */ +#screenerView .step-line { + width: 40px; + flex: 0 0 40px; + margin-inline: 10px; +} + +#screenerView .screener-card-heading { + min-height: 39px; + padding: 8px 14px; +} + +#screenerView .screener-strategy-card { min-height: 0; } + +#screenerView .screener-regime-body { + min-height: 0; + padding: 9px 14px; +} + +#screenerView .regime-summary { min-height: 56px; padding-block: 7px; } +#screenerView .regime-advice { margin-top: 3px; padding-block: 4px; } +#screenerView .regime-control-line { margin-top: 5px; } +#screenerView .regime-option { min-height: 24px; } + +#screenerView .screener-strategy-summary { + min-height: 0; + padding: 10px 14px; +} + +#screenerView .screener-strategy-summary > p { min-height: 0; flex: 0 0 auto; margin: 4px 0 5px; line-height: 1.55; } +#screenerView .screener-strategy-actions { margin-top: 0; padding-top: 0; } + +#screenerView .quant-filter-row { + grid-template-columns: 220px 72px 96px 26px; + justify-content: start; +} + +#screenerView .screener-tracking-entry { + border-color: #9db9ee; + background: #eff4ff; + color: #1d4ed8; + font-weight: 700; + box-shadow: 0 2px 8px rgba(37, 99, 235, .12); + transition: transform 160ms ease, border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease; +} + +#screenerView .screener-tracking-entry .lucide { + width: 21px; + height: 21px; + padding: 4px; + border-radius: 5px; + background: var(--scr-blue); + color: #fff; +} + +#screenerView .screener-tracking-entry:hover { + border-color: #7298e0; + background: #e7efff; + color: #1746a2; + box-shadow: 0 4px 11px rgba(37, 99, 235, .16); + transform: translateY(-1px); +} + +#screenerView .screener-tracking-entry:focus-visible { + outline: 2px solid rgba(37, 99, 235, .45); + outline-offset: 2px; +} + +@media (max-width: 620px) { + #screenerView .step-line { width: 22px; flex-basis: 22px; margin-inline: 6px; } + #screenerView .quant-filter-row { grid-template-columns: 1fr; } + #screenerView .screener-tracking-entry .lucide { width: 21px; height: 21px; } +} + +@media (prefers-reduced-motion: reduce) { + #screenerView .screener-tracking-entry { transition: none; } + #screenerView .screener-tracking-entry:hover { transform: none; } +} + +/* Stage 16: rebuild mentor as a compact model library beside one quiet conversation canvas. */ +#mentorView { + --mentor-blue: #2563eb; + --mentor-blue-dark: #1d4ed8; + --mentor-blue-soft: #eff4ff; + --mentor-blue-line: #c7d8fb; + --mentor-line: #e5e7eb; + --mentor-line-soft: #eef0f3; + --mentor-ink: #1f2937; + --mentor-sub: #6b7280; + --mentor-faint: #9ca3af; + color: var(--mentor-ink); +} + +#mentorView .mentor-page-header { + min-height: 42px; + display: flex; + align-items: center; + gap: 18px; + margin-bottom: 12px; + padding: 0; + border: 0; + background: transparent; +} + +#mentorView .mentor-page-title { + min-width: 0; + display: flex; + align-items: baseline; + gap: 10px; +} + +#mentorView .mentor-page-title h2 { + margin: 0; + color: var(--mentor-ink); + font-size: 18px; + font-weight: 760; + letter-spacing: 0; +} + +#mentorView .mentor-page-title .section-subtitle { + overflow: hidden; + color: var(--mentor-faint); + font-size: 11.5px; + text-overflow: ellipsis; + white-space: nowrap; +} + +#mentorView .mentor-page-controls { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +#mentorView .mentor-evidence-filters { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px; + border: 0; + border-radius: 8px; + background: #eef0f3; +} + +#mentorView .mentor-evidence-filters button { + min-width: 52px; + min-height: 28px; + padding: 0 12px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--mentor-sub); + font: inherit; + font-size: 12px; + cursor: pointer; + transition: color 150ms ease, background-color 150ms ease, box-shadow 150ms ease; +} + +#mentorView .mentor-evidence-filters button:hover { color: var(--mentor-blue); } +#mentorView .mentor-evidence-filters button.active { + background: #fff; + color: var(--mentor-ink); + font-weight: 650; + box-shadow: 0 1px 2px rgba(16, 24, 40, .09); +} + +#mentorView .mentor-evidence-filters button[data-mentor-grade="A"] { color: #16814a; } +#mentorView .mentor-evidence-filters button[data-mentor-grade="B"] { color: #2e67c7; } +#mentorView .mentor-evidence-filters button[data-mentor-grade="C"] { color: #a76608; } +#mentorView .mentor-evidence-filters button:focus-visible { + outline: 2px solid rgba(37, 99, 235, .35); + outline-offset: 1px; +} + +#mentorView #mentorNotice { margin: 0 0 10px; } + +#mentorView .mentor-layout { + height: auto; + min-height: 0; + display: grid; + grid-template-columns: 340px minmax(0, 1fr); + align-items: stretch; + gap: 12px; + overflow: visible; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +@media (min-width: 721px) { + body[data-active-view="mentorView"] .app-main { + min-height: 0; + display: flex; + flex-direction: column; + padding-bottom: 0; + overflow: hidden; + } + + body[data-active-view="mentorView"] .overview-strip { flex: 0 0 auto; } + + body[data-active-view="mentorView"] #mentorView.active-view { + min-height: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; + overflow: hidden; + padding-bottom: 6px; + } + + #mentorView .member-gate, + #mentorView .mentor-page-header, + #mentorView #mentorNotice { flex: 0 0 auto; } + + #mentorView .mentor-layout { + min-height: 0; + flex: 1 1 auto; + } +} + +#mentorView .mentor-sidebar, +#mentorView .mentor-chat-panel { + min-width: 0; + min-height: 0; + overflow: hidden; + border: 1px solid var(--mentor-line); + border-radius: 10px; + background: #fff; + box-shadow: 0 1px 2px rgba(16, 24, 40, .05); +} + +#mentorView .mentor-sidebar { + position: relative; + padding: 0; +} + +#mentorView .mentor-directory-toggle, +#mentorView .mentor-directory-close, +#mentorView .mentor-directory-backdrop { display: none; } + +#mentorView .mentor-directory-content { + height: 100%; + min-height: 0; + display: grid; + grid-template-rows: auto auto auto minmax(0, 1fr) auto; + gap: 0; + padding: 0; + background: #fff; +} + +#mentorView .mentor-directory-heading, +#mentorView .mentor-chat-header { + border-bottom: 1px solid var(--mentor-line-soft); + background: #fff; +} + +#mentorView .mentor-directory-heading { + min-height: 48px; + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px 9px 14px; +} + +#mentorView .mentor-directory-heading > .mentor-directory-title { + min-width: 0; + display: flex; + align-items: baseline; + gap: 8px; +} + +#mentorView .mentor-directory-title h3 { + flex: 0 0 auto; + margin: 0; + color: var(--mentor-ink); + font-size: 14px; + font-weight: 700; +} + +#mentorView .mentor-directory-title > span { + overflow: hidden; + color: var(--mentor-faint); + font-size: 10.5px; + text-overflow: ellipsis; + white-space: nowrap; +} + +#mentorView .mentor-directory-actions { + display: flex; + align-items: center; + gap: 5px; + margin-left: auto; +} + +#mentorView .mentor-count { + color: var(--mentor-faint); + font-size: 10.5px; + font-weight: 500; + white-space: nowrap; +} + +#mentorView .mentor-sort-toggle { + min-height: 28px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0 7px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--mentor-blue); + font: inherit; + font-size: 11px; + cursor: pointer; +} + +#mentorView .mentor-sort-toggle:hover, +#mentorView .mentor-sort-toggle.active { + border-color: var(--mentor-blue-line); + background: var(--mentor-blue-soft); + color: var(--mentor-blue-dark); +} + +#mentorView .mentor-sort-toggle .lucide { width: 13px; height: 13px; } + +#mentorView .mentor-search-field { + height: 34px; + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + align-items: center; + gap: 7px; + margin: 9px 12px 7px; + padding: 0 9px; + border: 1px solid var(--mentor-line); + border-radius: 7px; + background: #fff; + color: var(--mentor-faint); +} + +#mentorView .mentor-search-field:focus-within { + border-color: var(--mentor-blue-line); + box-shadow: 0 0 0 2px rgba(37, 99, 235, .08); +} + +#mentorView .mentor-search-field .lucide { width: 14px; height: 14px; } +#mentorView .mentor-search-field input { + width: 100%; + min-width: 0; + height: 32px; + padding: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--mentor-ink); + font: inherit; + font-size: 12px; +} + +#mentorView .mentor-search-field input::placeholder { color: var(--mentor-faint); } + +#mentorView .mentor-sort-hint { + margin: 0; + padding: 3px 14px 7px; + color: var(--mentor-faint); + font-size: 10px; + line-height: 1.4; +} + +#mentorView .mentor-list { + min-height: 0; + display: block; + padding: 0; + overflow-x: hidden; + overflow-y: auto; + scrollbar-color: #d7dce4 transparent; + scrollbar-width: thin; +} + +#mentorView .mentor-option { + width: 100%; + min-height: 82px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: stretch; + gap: 0; + padding: 0 7px 0 0; + overflow: hidden; + border: 0; + border-bottom: 1px solid var(--mentor-line-soft); + border-radius: 0; + background: #fff; + color: var(--mentor-ink); + box-shadow: none; + transition: background-color 150ms ease, box-shadow 150ms ease; +} + +#mentorView .mentor-option:hover { background: #f8faff; } +#mentorView .mentor-option.active { + background: var(--mentor-blue-soft); + box-shadow: inset 2px 0 0 var(--mentor-blue); +} + +#mentorView .mentor-option.is-dragging { opacity: .45; } +#mentorView .mentor-option.is-drag-over { + background: #e7efff; + box-shadow: inset 3px 0 0 var(--mentor-blue); +} + +#mentorView .mentor-option-main { + min-width: 0; + min-height: 81px; + display: block; + padding: 10px 7px 9px 14px; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + text-align: left; +} + +#mentorView .mentor-option-main:disabled { cursor: default; } +#mentorView .mentor-option-main:focus-visible { + outline: 2px solid rgba(37, 99, 235, .42); + outline-offset: -3px; +} + +#mentorView .mentor-option-copy { + min-width: 0; + display: grid; + gap: 4px; + margin: 0; + color: inherit; + line-height: normal; +} + +#mentorView .mentor-option-heading { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; +} + +#mentorView .mentor-option-heading > strong { + min-width: 0; + overflow: hidden; + color: var(--mentor-ink); + font-size: 13px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +#mentorView .mentor-option-copy > em { + display: block; + overflow: hidden; + color: var(--mentor-sub); + font-size: 11.5px; + font-style: normal; + line-height: 1.55; + text-overflow: ellipsis; + white-space: nowrap; +} + +#mentorView .mentor-option-meta { + min-width: 0; + display: flex; + align-items: center; + gap: 9px; + overflow: hidden; + color: var(--mentor-faint); + font-size: 10.5px; + line-height: 1.25; + white-space: nowrap; +} + +#mentorView .mentor-option-meta > span { + display: inline; + flex: 0 0 auto; + margin: 0; + color: inherit; + font-size: inherit; +} + +#mentorView .mentor-option-meta .mentor-evidence-source { + max-width: 108px; + overflow: hidden; + border-bottom: 1px dashed #c7cdd6; + text-overflow: ellipsis; + cursor: help; +} + +#mentorView .mentor-option-badges, +#mentorView .mentor-active-badges { + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-start; + flex-wrap: nowrap; + gap: 4px; + margin: 0; +} + +#mentorView .mentor-option-heading .mentor-option-badges { margin-left: auto; } +#mentorView .mentor-badge { + min-height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 3px; + margin: 0; + padding: 0 6px; + border: 1px solid var(--mentor-line); + border-radius: 5px; + background: #f3f4f6; + color: var(--mentor-sub); + font-size: 9.5px; + font-style: normal; + font-weight: 700; + line-height: 1; + white-space: nowrap; +} + +#mentorView .mentor-badge .lucide { width: 10px; height: 10px; } +#mentorView .mentor-badge.grade-a { border-color: #b8dcc7; background: #edf8f1; color: #16814a; } +#mentorView .mentor-badge.grade-b { border-color: #bdd0ed; background: #eff5ff; color: #2e67c7; } +#mentorView .mentor-badge.grade-c { border-color: #ead1a7; background: #fdf5e8; color: #a76608; } +#mentorView .mentor-badge.private { border-color: #e8d09c; background: #fff8e7; color: #926713; } +#mentorView .mentor-badge.quality { border-color: transparent; background: #f3f4f6; color: var(--mentor-faint); } +#mentorView .mentor-badge.quality.conditional { border-color: #decda9; border-style: dashed; color: #8b681f; } + +#mentorView .mentor-option-tools { + display: flex; + align-items: center; + align-self: center; + gap: 1px; +} + +#mentorView .mentor-pin-button, +#mentorView .mentor-order-button { + width: 26px; + min-width: 26px; + height: 28px; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: #a1a8b3; + cursor: pointer; +} + +#mentorView .mentor-pin-button:hover, +#mentorView .mentor-order-button:hover:not(:disabled) { background: #eef1f5; color: var(--mentor-ink); } +#mentorView .mentor-pin-button.active { background: #fff4d8; color: #a36d0d; } +#mentorView .mentor-pin-button.active .lucide { fill: currentColor; } +#mentorView .mentor-pin-button .lucide, +#mentorView .mentor-order-button .lucide { width: 13px; height: 13px; } +#mentorView .mentor-order-button:disabled { opacity: .3; cursor: default; } +#mentorView .mentor-list.is-sorting .mentor-option { cursor: grab; } +#mentorView .mentor-list.is-sorting .mentor-option-badges { display: none; } + +#mentorView .mentor-list-empty { + padding: 32px 14px; + color: var(--mentor-faint); + font-size: 12px; + text-align: center; +} + +#mentorView .mentor-evidence-legend { + margin: 0; + padding: 8px 12px 9px; + border-top: 1px solid var(--mentor-line-soft); + color: var(--mentor-faint); + font-size: 9.5px; + line-height: 1.5; +} + +#mentorView .mentor-chat-panel { + height: 100%; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto auto auto; +} + +#mentorView .mentor-chat-header { + min-height: 69px; + display: flex; + align-items: center; + gap: 14px; + padding: 10px 14px; +} + +#mentorView .mentor-active-profile { + min-width: 0; + display: grid; + gap: 3px; +} + +#mentorView .mentor-active-title { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; +} + +#mentorView .mentor-active-title h3 { + min-width: 0; + margin: 0; + overflow: hidden; + color: var(--mentor-ink); + font-size: 14px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +#mentorView .mentor-active-profile > p { + max-width: min(760px, 72vw); + margin: 0; + overflow: hidden; + color: var(--mentor-sub); + font-size: 10.5px; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +#mentorView .mentor-active-focus { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; + overflow: hidden; +} + +#mentorView .mentor-active-focus span { + padding: 0; + background: transparent; + color: var(--mentor-faint); + font-size: 9.5px; + white-space: nowrap; +} + +#mentorView .mentor-active-focus span::before { content: "#"; } +#mentorView .mentor-clear-button { + min-width: 0; + min-height: 28px; + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: auto; + padding: 0 8px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--mentor-blue); + font-size: 11px; + white-space: nowrap; +} + +#mentorView .mentor-clear-button:hover:not(:disabled) { border-color: var(--mentor-blue-line); background: var(--mentor-blue-soft); } +#mentorView .mentor-clear-button:disabled { color: #b8bec7; opacity: 1; } +#mentorView .mentor-clear-button .lucide { width: 13px; height: 13px; } + +#mentorView .mentor-messages { + min-width: 0; + min-height: 0; + max-height: none; + overflow-y: auto; + padding: 18px; + background: #fbfcfd; + scrollbar-color: #d7dce4 transparent; + scrollbar-width: thin; +} + +#mentorView .mentor-empty-state { + min-height: 100%; + display: grid; + place-content: center; + justify-items: center; + padding: 28px; + color: var(--mentor-faint); + text-align: center; +} + +#mentorView .mentor-empty-mark { + width: 44px; + height: 40px; + display: grid; + place-items: center; + margin-bottom: 12px; + border: 0; + border-radius: 8px; + background: #f3f6fb; + color: #6482b7; +} + +#mentorView .mentor-empty-mark .lucide { + width: 21px; + height: 21px; + stroke-width: 1.65; +} + +#mentorView .mentor-empty-state strong { color: #4b5563; font-size: 13px; font-weight: 600; } +#mentorView .mentor-empty-state p { + max-width: 580px; + margin: 7px auto 0; + color: var(--mentor-faint); + font-size: 11.5px; + line-height: 1.7; +} + +#mentorView .mentor-message { + width: fit-content; + max-width: min(84%, 820px); + margin: 0 0 12px; + padding: 10px 12px; + border: 1px solid var(--mentor-line); + border-radius: 8px; + background: #fff; + box-shadow: 0 1px 1px rgba(16, 24, 40, .025); +} + +#mentorView .mentor-message.user { + margin-left: auto; + border-color: var(--mentor-blue-line); + background: var(--mentor-blue-soft); +} + +#mentorView .mentor-message.assistant { border-left: 2px solid var(--mentor-blue); } +#mentorView .mentor-message-label { + margin-bottom: 5px; + color: var(--mentor-faint); + font-size: 10.5px; + font-weight: 650; +} + +#mentorView .mentor-message-content { + margin: 0; + overflow-wrap: anywhere; + color: #374151; + font-size: 13px; + line-height: 1.62; + white-space: normal; +} + +#mentorView .mentor-message .mentor-answer-paragraph { margin: 0 0 6px; line-height: inherit; } +#mentorView .mentor-message .mentor-answer-paragraph:last-child { margin-bottom: 0; } +#mentorView .mentor-answer-heading { display: block; margin: 9px 0 4px; color: var(--mentor-ink); font-size: 13px; line-height: 1.45; } +#mentorView .mentor-message-content > .mentor-answer-heading:first-child { margin-top: 0; } +#mentorView .mentor-answer-list { margin: 3px 0 7px; padding-left: 20px; } +#mentorView .mentor-answer-list li + li { margin-top: 3px; } +#mentorView .mentor-answer-rule { display: block; height: 1px; margin: 8px 0; background: var(--mentor-line); } +#mentorView .mentor-answer-quote { display: block; padding-left: 9px; border-left: 2px solid var(--mentor-blue); color: var(--mentor-sub); } +#mentorView .mentor-message small { display: block; margin-top: 7px; color: var(--mentor-faint); font-size: 9.5px; } +#mentorView .mentor-message.is-error { border-color: #e5a5a0; } + +#mentorView .mentor-quick-prompts { + min-height: 42px; + display: flex; + align-items: center; + gap: 7px; + padding: 7px 14px; + overflow-x: auto; + border-top: 1px solid var(--mentor-line-soft); + background: #fff; + scrollbar-width: none; +} + +#mentorView .mentor-quick-prompts::-webkit-scrollbar { display: none; } +#mentorView .mentor-prompt-label { + flex: 0 0 auto; + color: var(--mentor-faint); + font-size: 10.5px; + white-space: nowrap; +} + +#mentorView .mentor-quick-prompts button { + min-height: 26px; + flex: 0 0 auto; + padding: 0 10px; + border: 1px solid var(--mentor-line); + border-radius: 14px; + background: #fff; + color: var(--mentor-sub); + font-size: 11px; + white-space: nowrap; + cursor: pointer; + transition: border-color 150ms ease, color 150ms ease, background-color 150ms ease; +} + +#mentorView .mentor-quick-prompts button:hover { border-color: var(--mentor-blue-line); background: var(--mentor-blue-soft); color: var(--mentor-blue); } + +#mentorView .mentor-chat-form { + min-height: 55px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-top: 1px solid var(--mentor-line-soft); + background: #fff; +} + +#mentorView .mentor-chat-form textarea { + width: 100%; + min-width: 0; + height: 38px; + min-height: 38px; + max-height: 88px; + padding: 8px 11px; + resize: vertical; + border: 1px solid var(--mentor-line); + border-radius: 8px; + outline: 0; + background: #fff; + color: var(--mentor-ink); + font: inherit; + font-size: 12.5px; + line-height: 1.55; +} + +#mentorView .mentor-chat-form textarea:focus { + border-color: var(--mentor-blue-line); + box-shadow: 0 0 0 2px rgba(37, 99, 235, .08); +} + +#mentorView .mentor-chat-form .button { + min-width: 76px; + height: 38px; + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 0 13px; + border-radius: 7px; + background: var(--mentor-blue); + color: #fff; + font-size: 12.5px; +} + +#mentorView .mentor-chat-form .button:hover:not(:disabled) { background: var(--mentor-blue-dark); } +#mentorView .mentor-chat-form .button .lucide { width: 14px; height: 14px; } +#mentorView .mentor-disclaimer { + min-height: 23px; + margin: 0; + padding: 0 14px 8px; + background: #fff; + color: var(--mentor-faint); + font-size: 9.5px; + text-align: right; +} + +@media (min-width: 721px) and (max-width: 1100px) { + #mentorView .mentor-layout { grid-template-columns: 292px minmax(0, 1fr); } + #mentorView .mentor-directory-title > span { display: none; } + #mentorView .mentor-active-profile > p { max-width: 48vw; } +} + +@media (max-width: 720px) { + body.mentor-directory-open { overflow: hidden; } + + #mentorView .mentor-page-header { + min-height: 84px; + align-items: flex-start; + flex-direction: column; + gap: 8px; + padding: 10px 12px 0; + } + + #mentorView .mentor-page-title { width: 100%; } + #mentorView .mentor-page-title h2 { font-size: 17px; } + #mentorView .mentor-page-title .section-subtitle { font-size: 10.5px; } + #mentorView .mentor-page-controls { width: 100%; margin: 0; } + #mentorView .mentor-evidence-filters { width: 100%; } + #mentorView .mentor-evidence-filters button { min-height: 36px; flex: 1; } + + #mentorView .mentor-layout { + height: calc(100dvh - 286px - env(safe-area-inset-bottom)); + min-height: 520px; + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: 56px minmax(0, 1fr); + gap: 8px; + padding: 0 8px; + overflow: hidden; + } + + #mentorView .mentor-sidebar { + height: 56px; + min-height: 56px; + overflow: visible; + border-radius: 9px; + } + + #mentorView .mentor-directory-toggle { + width: 100%; + min-height: 54px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0 12px; + border: 0; + background: #fff; + color: var(--mentor-ink); + cursor: pointer; + text-align: left; + } + + #mentorView .mentor-directory-toggle > span { min-width: 0; display: flex; align-items: center; gap: 10px; } + #mentorView .mentor-directory-toggle > span > span { min-width: 0; display: grid; gap: 2px; } + #mentorView .mentor-directory-toggle small { color: var(--mentor-faint); font-size: 9px; } + #mentorView .mentor-directory-toggle strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } + #mentorView .mentor-directory-toggle > .lucide { width: 16px; transition: transform 180ms ease; } + #mentorView .mentor-sidebar.is-open .mentor-directory-toggle > .lucide { transform: rotate(180deg); } + + #mentorView .mentor-directory-backdrop { + position: fixed; + inset: 0; + z-index: 79; + display: block; + background: rgba(20, 27, 33, .48); + } + + #mentorView .mentor-directory-content { + height: auto; + position: fixed; + inset: 62px 8px 72px; + z-index: 80; + overflow: hidden; + border: 1px solid var(--mentor-line); + border-radius: 10px; + box-shadow: 0 16px 36px rgba(16, 24, 40, .18); + opacity: 0; + pointer-events: none; + transform: translateY(10px); + transition: opacity 180ms ease, transform 180ms ease; + } + + #mentorView .mentor-sidebar.is-open .mentor-directory-content { opacity: 1; pointer-events: auto; transform: translateY(0); } + #mentorView .mentor-directory-close { width: 38px; height: 38px; display: grid; place-items: center; } + #mentorView .mentor-sort-toggle { min-height: 38px; } + #mentorView .mentor-search-field { height: 42px; } + #mentorView .mentor-search-field input { height: 40px; font-size: 16px; } + #mentorView .mentor-option { min-height: 84px; } + #mentorView .mentor-pin-button, + #mentorView .mentor-order-button { width: 40px; min-width: 40px; height: 42px; } + + #mentorView .mentor-chat-panel { height: 100%; min-height: 0; border-radius: 9px; } + #mentorView .mentor-chat-header { min-height: 68px; padding: 9px 10px; } + #mentorView .mentor-active-profile > p { max-width: calc(100vw - 130px); } + #mentorView .mentor-active-focus { max-width: calc(100vw - 130px); } + #mentorView .mentor-clear-button { width: 36px; height: 36px; justify-content: center; padding: 0; } + #mentorView .mentor-clear-button span { display: none; } + #mentorView .mentor-messages { padding: 12px 10px; } + #mentorView .mentor-empty-state { padding: 16px; } + #mentorView .mentor-empty-state p { display: none; } + #mentorView .mentor-message { max-width: 92%; } + #mentorView .mentor-quick-prompts { padding-inline: 10px; } + #mentorView .mentor-prompt-label { display: none; } + #mentorView .mentor-chat-form { padding: 7px 9px; } + #mentorView .mentor-chat-form .button { min-width: 42px; width: 42px; padding: 0; } + #mentorView .mentor-chat-form .button span { display: none; } + #mentorView .mentor-disclaimer { padding-inline: 10px; font-size: 8.5px; } +} + +@media (prefers-reduced-motion: reduce) { + #mentorView .mentor-option, + #mentorView .mentor-evidence-filters button, + #mentorView .mentor-quick-prompts button, + #mentorView .mentor-directory-content, + #mentorView .mentor-directory-toggle > .lucide { transition: none; } +} + +/* Stage 17: daily review workflow transferred from review.html. */ +#reviewWorkspaceView { + --review-blue: #2563eb; + --review-blue-dark: #1d4ed8; + --review-blue-soft: #eff4ff; + --review-blue-line: #c7d8fb; + --review-line: #e5e7eb; + --review-line-soft: #eef0f3; + --review-ink: #1f2937; + --review-sub: #6b7280; + --review-faint: #9ca3af; + color: var(--review-ink); +} + +#reviewWorkspaceView .review-page-header { + min-height: 42px; + display: flex; + align-items: center; + gap: 14px; + margin: 0 0 12px; + padding: 0; + border: 0; + background: transparent; +} + +#reviewWorkspaceView .review-page-title { + min-width: 0; + display: flex; + align-items: baseline; + gap: 10px; +} + +#reviewWorkspaceView .review-page-title h2 { + margin: 0; + color: var(--review-ink); + font-size: 18px; + font-weight: 760; +} + +#reviewWorkspaceView .review-page-title .section-subtitle { + overflow: hidden; + color: var(--review-faint); + font-size: 11.5px; + text-overflow: ellipsis; + white-space: nowrap; +} + +#reviewWorkspaceView .review-page-title .section-subtitle b { + color: var(--review-sub); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +#reviewWorkspaceView .review-history-toggle { + min-height: 30px; + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: auto; + padding: 0 10px; + border: 1px solid var(--review-line); + border-radius: 7px; + background: #fff; + color: #374151; + font-size: 11.5px; + white-space: nowrap; +} + +#reviewWorkspaceView .review-history-toggle:hover, +#reviewWorkspaceView .review-history-toggle[aria-expanded="true"] { + border-color: var(--review-blue-line); + background: var(--review-blue-soft); + color: var(--review-blue); +} + +#reviewWorkspaceView .review-history-toggle .lucide { width: 14px; height: 14px; } + +#reviewWorkspaceView .review-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + grid-template-areas: "left journal" "history history"; + align-items: start; + gap: 12px; + border: 0; + background: transparent; + box-shadow: none; +} + +#reviewWorkspaceView .review-left-stack { + min-width: 0; + display: flex; + grid-area: left; + flex-direction: column; + gap: 12px; +} + +#reviewWorkspaceView .journal-section { min-width: 0; grid-area: journal; } +#reviewWorkspaceView .notes-history-section { min-width: 0; grid-area: history; } +#reviewWorkspaceView .notes-history-section[hidden] { display: none !important; } + +#reviewWorkspaceView .workspace-section { + min-width: 0; + overflow: hidden; + border: 1px solid var(--review-line); + border-radius: 10px; + background: #fff; + box-shadow: 0 1px 2px rgba(16, 24, 40, .05); +} + +#reviewWorkspaceView .review-card-heading { + min-height: 46px; + display: flex; + align-items: center; + gap: 9px; + padding: 9px 14px; + border-bottom: 1px solid var(--review-line-soft); + background: #fff; +} + +#reviewWorkspaceView .review-card-heading > div { + min-width: 0; + display: flex; + align-items: baseline; + gap: 8px; +} + +#reviewWorkspaceView .review-card-heading h3 { + margin: 0; + color: var(--review-ink); + font-size: 14px; + font-weight: 700; +} + +#reviewWorkspaceView .review-card-heading > small { + margin-left: auto; + color: var(--review-faint); + font-size: 10.5px; + white-space: nowrap; +} + +#reviewWorkspaceView .review-count-tag { + min-height: 20px; + display: inline-flex; + align-items: center; + padding: 0 7px; + border-radius: 5px; + background: #f3f4f6; + color: var(--review-sub); + font-size: 10.5px; + font-weight: 500; + white-space: nowrap; +} + +#reviewWorkspaceView .watchlist-section .workspace-table-frame { + min-height: 0; + max-height: 238px; + overflow: auto; + border: 0; + border-radius: 0; +} + +#reviewWorkspaceView .review-watchlist-table { width: 100%; min-width: 540px; table-layout: fixed; } +#reviewWorkspaceView .review-watchlist-table th:nth-child(1) { width: 58px; } +#reviewWorkspaceView .review-watchlist-table th:nth-child(2) { width: 170px; } +#reviewWorkspaceView .review-watchlist-table th:nth-child(4) { width: 112px; } +#reviewWorkspaceView .data-table thead th { + height: 32px; + padding: 6px 12px; + border-bottom: 1px solid var(--review-line-soft); + background: #fafbfc; + color: var(--review-faint); + font-size: 10.5px; + font-weight: 600; +} + +#reviewWorkspaceView .data-table tbody td { + height: 48px; + padding: 7px 12px; + border-bottom: 1px solid var(--review-line-soft); + color: #374151; + font-size: 12px; +} + +#reviewWorkspaceView .data-table tbody tr:last-child td { border-bottom: 0; } +#reviewWorkspaceView .data-table tbody tr:hover { background: #f8faff; } +#reviewWorkspaceView .stock-cell { display: grid; gap: 2px; } +#reviewWorkspaceView .stock-cell strong { color: var(--review-ink); font-size: 12px; } +#reviewWorkspaceView .stock-cell small { color: var(--review-faint); font-size: 10px; } + +#reviewWorkspaceView .review-watch-mark { + color: #d1d5db; + font-size: 15px; + line-height: 1; +} + +#reviewWorkspaceView .review-watch-mark.red { color: #e04536; } +#reviewWorkspaceView .review-watch-mark.orange, +#reviewWorkspaceView .review-watch-mark.yellow { color: #e9a21b; } +#reviewWorkspaceView .review-watch-mark.green { color: #16a34a; } +#reviewWorkspaceView .review-watch-mark.blue { color: #3b82f6; } +#reviewWorkspaceView .review-watch-mark.purple { color: #8b5cf6; } + +#reviewWorkspaceView .review-row-actions, +#reviewWorkspaceView .trade-row-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 3px; +} + +#reviewWorkspaceView .table-action { + min-height: 25px; + padding: 0 6px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--review-blue); + font-size: 10.5px; +} + +#reviewWorkspaceView .table-action:hover { background: var(--review-blue-soft); } +#reviewWorkspaceView .table-action.down { color: #8b929e; } +#reviewWorkspaceView .table-action.down:hover { background: #f5f6f8; color: #d14343; } + +#reviewWorkspaceView .watchlist-section .empty-state, +#reviewWorkspaceView .trade-log-table-frame .empty-state { + min-height: 108px; + display: grid; + place-items: center; + padding: 22px; + color: var(--review-faint); + font-size: 11.5px; + text-align: center; +} + +#reviewWorkspaceView .trade-journal-section { padding: 0; } +#reviewWorkspaceView .trade-log-heading .button { + min-height: 29px; + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: auto; + padding: 0 10px; + border-radius: 7px; + background: var(--review-blue); + color: #fff; + font-size: 11.5px; +} + +#reviewWorkspaceView .trade-log-heading .button:hover { background: var(--review-blue-dark); } +#reviewWorkspaceView .trade-log-heading .button .lucide { width: 13px; height: 13px; } +#reviewWorkspaceView .trade-log-summary { + min-height: 52px; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + border: 0; + border-bottom: 1px solid var(--review-line-soft); + background: #fff; +} + +#reviewWorkspaceView .trade-log-summary:empty { display: none; } +#reviewWorkspaceView .trade-log-summary > div { + min-width: 0; + padding: 8px 11px; + border-right: 1px solid var(--review-line-soft); +} + +#reviewWorkspaceView .trade-log-summary > div:last-child { border-right: 0; } +#reviewWorkspaceView .trade-log-summary span { + display: block; + overflow: hidden; + color: var(--review-faint); + font-size: 9.5px; + text-overflow: ellipsis; + white-space: nowrap; +} + +#reviewWorkspaceView .trade-log-summary strong { + display: block; + margin-top: 3px; + overflow: hidden; + color: var(--review-ink); + font-size: 13px; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; +} + +#reviewWorkspaceView .trade-log-table-frame { + min-height: 108px; + max-height: 314px; + overflow: auto; + border: 0; + border-radius: 0; +} + +#reviewWorkspaceView .trade-log-table-frame thead th { + position: sticky; + z-index: 2; + top: 0; +} + +#reviewWorkspaceView .trade-log-table { width: 100%; min-width: 720px; table-layout: fixed; } +#reviewWorkspaceView .trade-log-table th:nth-child(1) { width: 82px; } +#reviewWorkspaceView .trade-log-table th:nth-child(2) { width: 122px; } +#reviewWorkspaceView .trade-log-table th:nth-child(3) { width: 66px; } +#reviewWorkspaceView .trade-log-table th:nth-child(4) { width: 104px; } +#reviewWorkspaceView .trade-log-table th:nth-child(5) { width: 126px; } +#reviewWorkspaceView .trade-log-table th:nth-child(7) { width: 92px; } + +#reviewWorkspaceView .trade-position-cell { display: grid; justify-items: end; gap: 3px; } +#reviewWorkspaceView .trade-position-cell strong { color: var(--review-ink); font-size: 11.5px; } +#reviewWorkspaceView .trade-position-cell small { color: var(--review-faint); font-size: 9.5px; white-space: nowrap; } +#reviewWorkspaceView .trade-position-cell small.up { color: #e04536; } +#reviewWorkspaceView .trade-position-cell small.down { color: #16a34a; } + +#reviewWorkspaceView .trade-action, +#reviewWorkspaceView .trade-emotion { + min-height: 21px; + display: inline-flex; + align-items: center; + padding: 0 6px; + border: 1px solid var(--review-line); + border-radius: 5px; + background: #f8f9fb; + color: var(--review-sub); + font-size: 9.5px; + white-space: nowrap; +} + +#reviewWorkspaceView .trade-action-buy, +#reviewWorkspaceView .trade-action-add { border-color: #f1c4c0; background: #fdecea; color: #d63e32; } +#reviewWorkspaceView .trade-action-sell, +#reviewWorkspaceView .trade-action-trim { border-color: #bce1ca; background: #eaf7ef; color: #168b43; } +#reviewWorkspaceView .trade-tags { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 4px; } +#reviewWorkspaceView .trade-tags em { padding: 1px 4px; border-radius: 3px; background: var(--review-blue-soft); color: #5270a7; font-size: 8.5px; } + +#reviewWorkspaceView .trade-copy { + overflow: hidden; + color: var(--review-sub); +} + +#reviewWorkspaceView .trade-copy strong, +#reviewWorkspaceView .trade-copy small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#reviewWorkspaceView .trade-copy strong { color: #4b5563; font-size: 10.5px; font-weight: 600; } +#reviewWorkspaceView .trade-copy small { margin-top: 3px; color: var(--review-faint); font-size: 9.5px; } + +#reviewWorkspaceView .journal-section { align-self: start; } +#reviewWorkspaceView .journal-section .review-card-heading { justify-content: space-between; } +#reviewWorkspaceView .journal-section .date-input { + width: 124px; + min-height: 28px; + margin-left: auto; + padding: 0 8px; + border: 1px solid var(--review-line); + border-radius: 6px; + background: #f8f9fb; + color: var(--review-sub); + font-size: 10.5px; +} + +#reviewWorkspaceView .journal-form { + display: grid; + gap: 13px; + padding: 14px 16px 16px; + background: #fff; +} + +#reviewWorkspaceView .journal-form .form-field { display: grid; gap: 6px; } +#reviewWorkspaceView .journal-form .form-field > span { + color: #374151; + font-size: 11.5px; + font-weight: 650; +} + +#reviewWorkspaceView .journal-form textarea { + width: 100%; + height: 208px; + min-height: 208px; + padding: 10px 11px; + resize: vertical; + border: 1px solid var(--review-line); + border-radius: 8px; + outline: 0; + background: #fff; + color: var(--review-ink); + font: inherit; + font-size: 12.5px; + line-height: 1.75; +} + +#reviewWorkspaceView .journal-form .form-field:nth-of-type(2) textarea { + height: 104px; + min-height: 104px; +} + +#reviewWorkspaceView .journal-form textarea:focus { + border-color: var(--review-blue-line); + box-shadow: 0 0 0 2px rgba(37, 99, 235, .08); +} + +#reviewWorkspaceView .journal-form textarea::placeholder { color: #a6adb7; } +#reviewWorkspaceView .journal-form-hint { + margin: -2px 0 0; + color: var(--review-faint); + font-size: 9.5px; + line-height: 1.5; +} + +#reviewWorkspaceView .journal-form .dialog-actions { + display: flex; + justify-content: flex-end; + margin: 0; +} + +#reviewWorkspaceView .journal-form .button { + min-height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 0 14px; + border-radius: 7px; + background: var(--review-blue); + color: #fff; + font-size: 12px; +} + +#reviewWorkspaceView .journal-form .button:hover { background: var(--review-blue-dark); } +#reviewWorkspaceView .journal-form .button .lucide { width: 14px; height: 14px; } + +#reviewWorkspaceView .notes-history-section { margin-top: 0; } +#reviewWorkspaceView .notes-history { + max-height: 420px; + display: grid; + overflow-y: auto; + background: #fff; +} + +#reviewWorkspaceView .note-row { + grid-template-columns: 88px minmax(0, .8fr) minmax(0, 1.2fr) minmax(0, 1fr) auto; + min-height: 78px; + padding: 12px 14px; + border-bottom: 1px solid var(--review-line-soft); +} + +@media (max-width: 900px) { + #reviewWorkspaceView .note-row { grid-template-columns: 80px repeat(2, minmax(0, 1fr)) auto; } + #reviewWorkspaceView .note-row .note-block:nth-of-type(3) { grid-column: 2 / -1; } +} + +@media (max-width: 720px) { + #reviewWorkspaceView .note-row { grid-template-columns: 1fr auto; } + #reviewWorkspaceView .note-row .note-block, + #reviewWorkspaceView .note-row .note-block:nth-of-type(3) { grid-column: 1 / -1; } +} + +#reviewWorkspaceView .note-row:last-child { border-bottom: 0; } + +@media (max-width: 1100px) { + #reviewWorkspaceView .review-workspace { grid-template-columns: minmax(0, 1fr) 340px; } + #reviewWorkspaceView .review-card-heading > small { display: none; } +} + +@media (max-width: 900px) { + #reviewWorkspaceView .review-workspace { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: "left" "journal" "history"; + } + + #reviewWorkspaceView .journal-section { width: 100%; } +} + +@media (max-width: 720px) { + #reviewWorkspaceView .review-page-header { + min-height: 68px; + align-items: flex-start; + padding: 10px 10px 0; + } + + #reviewWorkspaceView .review-page-title { display: grid; gap: 3px; } + #reviewWorkspaceView .review-page-title h2 { font-size: 17px; } + #reviewWorkspaceView .review-page-title .section-subtitle { max-width: calc(100vw - 145px); font-size: 9.5px; } + #reviewWorkspaceView .review-history-toggle { min-height: 38px; } + #reviewWorkspaceView .review-workspace { gap: 10px; padding: 0 8px; } + #reviewWorkspaceView .review-left-stack { gap: 10px; } + #reviewWorkspaceView .workspace-section { border-radius: 9px; } + #reviewWorkspaceView .review-card-heading { min-height: 44px; padding: 8px 11px; } + #reviewWorkspaceView .review-watchlist-table { min-width: 510px; } + #reviewWorkspaceView .trade-log-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + #reviewWorkspaceView .trade-log-summary > div { border-bottom: 1px solid var(--review-line-soft); } + #reviewWorkspaceView .trade-log-summary > div:nth-child(2n) { border-right: 0; } + #reviewWorkspaceView .trade-log-table { min-width: 700px; } + #reviewWorkspaceView .journal-form { padding: 12px; } + #reviewWorkspaceView .journal-form textarea { height: 180px; min-height: 180px; } + #reviewWorkspaceView .journal-form .form-field:nth-of-type(2) textarea { height: 96px; min-height: 96px; } + #reviewWorkspaceView .journal-form .button { width: 100%; } +} + +@media (prefers-reduced-motion: reduce) { + #reviewWorkspaceView .review-history-toggle, + #reviewWorkspaceView .table-action { transition: none; } +} + +/* Stage 17 refinement: complete review workspace from the approved prototype. */ +@media (min-width: 901px) { + #reviewWorkspaceView .review-workspace { align-items: stretch; } + #reviewWorkspaceView .review-left-stack, + #reviewWorkspaceView .journal-section { + height: clamp(540px, calc(100vh - 250px), 650px); + } + + #reviewWorkspaceView .review-left-stack { min-height: 0; } + #reviewWorkspaceView .watchlist-section { flex: 0 0 auto; } + #reviewWorkspaceView .trade-journal-section { + min-height: 0; + display: flex; + flex: 1 1 auto; + flex-direction: column; + } + + #reviewWorkspaceView .trade-log-table-frame { + min-height: 0; + max-height: none; + flex: 1 1 auto; + overflow: auto; + } + + #reviewWorkspaceView .journal-section { + min-height: 0; + display: flex; + align-self: stretch; + flex-direction: column; + } + + #reviewWorkspaceView .journal-form { + min-height: 0; + display: flex; + flex: 1 1 auto; + flex-direction: column; + } + + #reviewWorkspaceView .journal-form .form-field:nth-of-type(2) { min-height: 0; flex: 1 1 auto; } + #reviewWorkspaceView .journal-form .form-field:nth-of-type(2) textarea { + min-height: 112px; + height: 100%; + } +} + +#reviewWorkspaceView .review-page-title h2 { font-size: 19px; font-weight: 750; } +#reviewWorkspaceView .review-page-title .section-subtitle { font-size: 12px; } +#reviewWorkspaceView .review-card-heading { min-height: 48px; padding: 10px 14px; } +#reviewWorkspaceView .review-card-heading h3 { font-size: 14.5px; font-weight: 720; } + +#reviewWorkspaceView .review-add-watch { + min-height: 29px; + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: auto; + padding: 0 10px; + border: 1px solid #b9cdf8; + border-radius: 7px; + background: #f5f8ff; + color: var(--review-blue); + font-size: 11.5px; + font-weight: 650; +} + +#reviewWorkspaceView .review-add-watch:hover { border-color: #8eb0f4; background: #eaf1ff; } +#reviewWorkspaceView .review-add-watch .lucide { width: 13px; height: 13px; } +#reviewWorkspaceView .watchlist-section .workspace-table-frame { max-height: 220px; } +#reviewWorkspaceView .review-watchlist-table { min-width: 760px; } +#reviewWorkspaceView .review-watchlist-table th:nth-child(1) { width: 6%; } +#reviewWorkspaceView .review-watchlist-table th:nth-child(2) { width: 16%; } +#reviewWorkspaceView .review-watchlist-table th:nth-child(3) { width: 13%; } +#reviewWorkspaceView .review-watchlist-table th:nth-child(4), +#reviewWorkspaceView .review-watchlist-table th:nth-child(5) { width: 10%; } +#reviewWorkspaceView .review-watchlist-table th:nth-child(6) { width: 11%; } +#reviewWorkspaceView .review-watchlist-table th:nth-child(7) { width: 22%; } +#reviewWorkspaceView .review-watchlist-table th:nth-child(8) { width: 12%; } +#reviewWorkspaceView .review-watchlist-table td { overflow: hidden; } +#reviewWorkspaceView .review-watchlist-table .stock-cell strong { font-size: 12.5px; font-weight: 680; } +#reviewWorkspaceView .review-watchlist-table .stock-cell small { font-size: 10.5px; } +#reviewWorkspaceView .watch-attention-score { color: #3f4b5e; font-size: 12px; font-weight: 720; } +#reviewWorkspaceView .watch-remark { + display: block; + overflow: hidden; + color: var(--review-sub); + font-size: 11.5px; + text-overflow: ellipsis; + white-space: nowrap; +} + +#reviewWorkspaceView .journal-summary-field input { + width: 100%; + min-height: 38px; + padding: 0 11px; + border: 1px solid var(--review-line); + border-radius: 7px; + outline: 0; + background: #fff; + color: var(--review-ink); + font: inherit; + font-size: 12.5px; +} + +#reviewWorkspaceView .journal-summary-field input:focus { + border-color: var(--review-blue-line); + box-shadow: 0 0 0 2px rgba(37, 99, 235, .08); +} + +#reviewWorkspaceView .journal-summary-field input::placeholder { color: #a6adb7; } +#reviewWorkspaceView .journal-form textarea { height: 154px; min-height: 112px; resize: none; } +#reviewWorkspaceView .journal-form .form-field:nth-of-type(3) textarea { height: 92px; min-height: 82px; } +#reviewWorkspaceView .journal-form .form-field > span { font-size: 12px; font-weight: 650; } +#reviewWorkspaceView .journal-form-hint { margin-top: auto; font-size: 10px; } + +.watchlist-dialog { width: min(520px, calc(100vw - 28px)); } +.watchlist-editor-form { display: grid; gap: 14px; padding: 16px 18px 18px; } +.watchlist-editor-form .form-field { display: grid; gap: 7px; } +.watchlist-editor-form .form-field > label, +.watchlist-editor-form .form-field > span { color: #374151; font-size: 12px; font-weight: 650; } +.watchlist-search-control { + min-height: 40px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 11px; + border: 1px solid #dfe3e8; + border-radius: 8px; + background: #fff; +} +.watchlist-search-control:focus-within { border-color: #b8caf4; box-shadow: 0 0 0 3px rgba(37, 99, 235, .07); } +.watchlist-search-control .lucide { width: 15px; height: 15px; color: #9aa3b0; } +.watchlist-search-control input { min-width: 0; flex: 1; border: 0; outline: 0; color: #1f2937; font: inherit; font-size: 13px; } +.watchlist-search-results { max-height: 230px; display: grid; overflow-y: auto; border-radius: 8px; } +.watchlist-search-results button { + min-height: 48px; + display: flex; + align-items: center; + gap: 12px; + padding: 7px 10px; + border: 0; + border-bottom: 1px solid #eef0f3; + background: #fff; + color: #1f2937; + text-align: left; +} +.watchlist-search-results button:hover { background: #f5f8ff; } +.watchlist-search-results button > span { min-width: 0; display: grid; gap: 2px; } +.watchlist-search-results button strong { font-size: 12.5px; } +.watchlist-search-results button small { color: #8b94a1; font-size: 10.5px; } +.watchlist-search-results button > b { margin-left: auto; color: #697386; font-size: 11px; font-weight: 560; } +.watchlist-search-status { padding: 14px 10px; color: #8b94a1; font-size: 11.5px; text-align: center; } +.watchlist-selection { + min-height: 62px; + display: flex; + align-items: center; + gap: 11px; + padding: 10px 12px; + border: 1px solid #dce5f7; + border-radius: 8px; + background: #f7f9fe; +} +.watchlist-selection[hidden] { display: none; } +.watchlist-selection-icon { width: 34px; height: 34px; display: grid; flex: 0 0 auto; place-items: center; border-radius: 7px; background: #e8efff; color: #2563eb; } +.watchlist-selection-icon .lucide { width: 17px; height: 17px; } +.watchlist-selection > div { min-width: 0; display: grid; gap: 3px; } +.watchlist-selection strong { color: #1f2937; font-size: 13.5px; } +.watchlist-selection span { display: flex; gap: 8px; color: #7b8491; font-size: 10.5px; } +.watchlist-selection span b { color: #526071; font-weight: 600; } +.watchlist-selection span i { font-style: normal; } +.watchlist-selection .table-action { margin-left: auto; } +.watchlist-editor-form textarea { width: 100%; min-height: 92px; padding: 9px 10px; resize: vertical; border: 1px solid #dfe3e8; border-radius: 8px; outline: 0; font: inherit; font-size: 12.5px; line-height: 1.65; } +.watchlist-editor-form textarea:focus { border-color: #b8caf4; box-shadow: 0 0 0 3px rgba(37, 99, 235, .07); } + +#reviewWorkspaceView .trade-log-table th:nth-child(1) { width: 11%; } +#reviewWorkspaceView .trade-log-table th:nth-child(2) { width: 17%; } +#reviewWorkspaceView .trade-log-table th:nth-child(3) { width: 9%; } +#reviewWorkspaceView .trade-log-table th:nth-child(4) { width: 14%; } +#reviewWorkspaceView .trade-log-table th:nth-child(5) { width: 15%; } +#reviewWorkspaceView .trade-log-table th:nth-child(6) { width: 23%; } +#reviewWorkspaceView .trade-log-table th:nth-child(7) { width: 11%; } + +#reviewWorkspaceView .journal-form .form-field { + min-height: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +#reviewWorkspaceView .journal-form .form-field > span { + min-height: 0; + flex: 0 0 auto; + line-height: 18px; +} + +@media (min-width: 901px) { + #reviewWorkspaceView .journal-form .form-field:nth-of-type(2) textarea { + min-height: 112px; + height: auto; + flex: 1 1 auto; + } +} + +@media (max-width: 900px) { + #reviewWorkspaceView .review-left-stack, + #reviewWorkspaceView .journal-section { height: auto; } + #reviewWorkspaceView .trade-log-table-frame { max-height: 360px; } +} + +@media (max-width: 720px) { + #reviewWorkspaceView .review-watchlist-table { min-width: 760px; } + #reviewWorkspaceView .journal-form textarea { height: 150px; min-height: 120px; } + #reviewWorkspaceView .journal-form .form-field:nth-of-type(3) textarea { height: 96px; min-height: 96px; } +} + +/* Stage 18: global tools, details, account and administration dialogs. */ +:is( + .global-search-dialog, + .stock-dialog, + .settings-dialog:not(.heaven-reading-dialog) +) { + --dialog-line: #e1e6ec; + --dialog-line-strong: #cfd7e1; + --dialog-muted: #f7f9fb; + --dialog-ink: #1f2937; + --dialog-sub: #687586; + border: 1px solid var(--dialog-line-strong); + border-radius: 10px; + background: #fff; + color: var(--dialog-ink); + box-shadow: 0 26px 72px rgba(25, 36, 48, .2), 0 4px 14px rgba(25, 36, 48, .08); +} + +:is( + .global-search-dialog, + .stock-dialog, + .settings-dialog:not(.heaven-reading-dialog) +)::backdrop { + background: rgba(28, 39, 50, .46); + backdrop-filter: blur(3px); +} + +:is(.stock-dialog, .settings-dialog:not(.heaven-reading-dialog))[open] { + animation: stage18-dialog-enter 180ms cubic-bezier(.2, .78, .25, 1) both; +} + +@keyframes stage18-dialog-enter { + from { opacity: 0; transform: translateY(7px) scale(.992); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +:is(.stock-dialog, .settings-dialog:not(.heaven-reading-dialog)) .dialog-header { + position: sticky; + top: 0; + z-index: 8; + min-height: 66px; + padding: 12px 18px; + border-bottom: 1px solid var(--dialog-line); + background: rgba(255, 255, 255, .97); + backdrop-filter: blur(10px); +} + +:is(.stock-dialog, .settings-dialog:not(.heaven-reading-dialog)) .dialog-header h2 { + margin: 2px 0 0; + color: var(--dialog-ink); + font-size: 18px; + font-weight: 740; + line-height: 1.25; +} + +:is(.stock-dialog, .settings-dialog:not(.heaven-reading-dialog)) :is(.dialog-eyebrow, .detail-code) { + color: #7b8795; + font-size: 10.5px; + font-weight: 620; + line-height: 1.2; +} + +:is(.stock-dialog, .settings-dialog:not(.heaven-reading-dialog)) .dialog-header .icon-button { + width: 34px; + height: 34px; + border-radius: 7px; + color: #596678; +} + +:is(.stock-dialog, .settings-dialog:not(.heaven-reading-dialog)) .dialog-header .icon-button:hover { + border-color: #bec8d4; + background: #f5f7fa; +} + +:is(.stock-dialog, .settings-dialog:not(.heaven-reading-dialog)) :is(input, select, textarea):focus-visible { + border-color: #8eafe9; + outline: 0; + box-shadow: 0 0 0 3px rgba(37, 99, 235, .09); +} + +/* Global search remains a command palette, now using the same surface language. */ +.global-search-dialog { + width: min(660px, calc(100vw - 32px)); + max-height: min(620px, calc(100dvh - 48px)); + margin: 9vh auto auto; + overflow: hidden; +} + +.global-search-shell { max-height: min(620px, calc(100dvh - 48px)); } +.global-search-head { + min-height: 64px; + grid-template-columns: 22px minmax(0, 1fr) auto 34px; + gap: 10px; + padding: 0 12px 0 18px; + border-color: var(--dialog-line); +} +.global-search-head input { height: 62px; color: var(--dialog-ink); font-size: 15px; } +.global-search-head kbd { border-color: #d6dde5; border-radius: 5px; background: #f7f8fa; color: #7d8896; } +.global-search-results { max-height: min(536px, calc(100dvh - 112px)); padding: 7px; } +.global-search-group-title { padding: 9px 10px 5px; color: #8792a0; font-size: 10px; } +.global-search-result { min-height: 52px; border-radius: 7px; } +.global-search-result:hover, +.global-search-result.is-active { background: #eef4ff; color: #1f55a5; } +.global-search-result-icon { border-color: #e0e5eb; border-radius: 7px; background: #fafbfc; } +.global-search-empty { min-height: 190px; } + +/* Stock and entity details use one consistent right-side workspace. */ +.stock-dialog { + width: min(810px, calc(100vw - 24px)); + max-height: calc(100dvh - 24px); + margin: 12px 12px 12px auto; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; +} + +.stock-dialog .dialog-header-actions { flex-wrap: wrap; justify-content: flex-end; gap: 7px; } +.stock-dialog .dialog-header-actions .button { min-height: 32px; padding-inline: 10px; border-radius: 7px; font-size: 11px; } +.stock-dialog .detail-price-line { + gap: 11px; + padding: 15px 18px 14px; + border-color: var(--dialog-line); + background: linear-gradient(180deg, #fff 0%, #fbfcfe 100%); +} +.stock-dialog .detail-price-line strong { font-size: 29px; line-height: 1; } +.stock-dialog .detail-price-line > span:nth-child(2) { font-size: 14px; font-weight: 650; } +.stock-dialog .streak-pill { padding: 4px 7px; border-radius: 5px; font-size: 10px; } +.stock-dialog .stock-chart-section { padding: 15px 18px 14px; border-color: var(--dialog-line); } +.stock-dialog .detail-section-heading { margin-bottom: 10px; } +.stock-dialog .detail-section-heading h3, +.stock-dialog .detail-section h3 { color: #263342; font-size: 13.5px; font-weight: 720; } +.stock-dialog .chart-heading-controls > span { max-width: 240px; overflow: hidden; color: #7a8695; font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } +.stock-dialog .chart-mode-toggle.segmented { height: 30px; padding: 2px; border-radius: 7px; } +.stock-dialog .chart-mode-toggle .segment { min-width: 43px; border-radius: 5px; font-size: 11px; } +.stock-dialog .price-chart { height: 282px; border-color: var(--dialog-line); border-radius: 7px; background: #fbfcfd; } +.stock-dialog .detail-section { padding: 15px 18px; border-color: var(--dialog-line); } +.stock-dialog .detail-section > h3 { margin: 0 0 11px; } +.stock-dialog .moneyflow-grid, +.stock-dialog .detail-grid { + overflow: hidden; + margin-top: 10px; + border: 1px solid var(--dialog-line); + border-radius: 7px; + background: #fff; +} +.stock-dialog .moneyflow-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.stock-dialog .detail-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.stock-dialog :is(.moneyflow-grid, .detail-grid) > div { + min-height: 58px; + padding: 10px 11px; + border: 0; + border-right: 1px solid var(--dialog-line); + border-bottom: 1px solid var(--dialog-line); + background: #fff; +} +.stock-dialog .moneyflow-grid > div:last-child { border-right: 0; } +.stock-dialog .detail-grid > div:nth-child(3n) { border-right: 0; } +.stock-dialog .detail-grid > div:nth-last-child(-n + 3) { border-bottom: 0; } +.stock-dialog :is(.moneyflow-grid, .detail-grid) dt { color: #7b8794; font-size: 10.5px; } +.stock-dialog :is(.moneyflow-grid, .detail-grid) dd { margin-top: 5px; font-size: 13px; } +.stock-dialog .inline-edit-form input { min-width: 0; flex: 1; } +.stock-dialog .journal-form.compact-form { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 0; padding: 0; } +.stock-dialog .journal-form.compact-form .dialog-actions { grid-column: 1 / -1; } +.stock-dialog .compact-form textarea { min-height: 84px; } +.stock-dialog .compact-notes { max-height: 240px; overflow-y: auto; } +.entity-detail-dialog .detail-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + +/* Shared settings geometry and section hierarchy. */ +.settings-dialog:not(.heaven-reading-dialog) { + width: min(740px, calc(100vw - 28px)); + max-height: min(820px, calc(100dvh - 28px)); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; +} +.settings-dialog:not(.heaven-reading-dialog) .settings-section { + padding: 18px 20px; + border-color: var(--dialog-line); +} +.settings-dialog:not(.heaven-reading-dialog) .settings-section-heading { margin-bottom: 12px; } +.settings-dialog:not(.heaven-reading-dialog) .settings-section-heading h3 { + margin: 0; + color: #273443; + font-size: 14px; + font-weight: 720; +} +.settings-dialog:not(.heaven-reading-dialog) .settings-section-heading > span { + color: #7c8795; + font-size: 10.5px; +} +.settings-dialog:not(.heaven-reading-dialog) .form-field { gap: 6px; } +.settings-dialog:not(.heaven-reading-dialog) .form-field > span, +.settings-dialog:not(.heaven-reading-dialog) .form-field > label { color: #4e5b6a; font-size: 11px; font-weight: 650; } +.settings-dialog:not(.heaven-reading-dialog) :is(input, select, textarea) { border-color: #d5dce5; border-radius: 7px; } +.settings-dialog:not(.heaven-reading-dialog) .dialog-actions { gap: 8px; } + +/* Reminder center: creation stays compact while history scrolls independently. */ +.settings-dialog.alerts-dialog { width: min(720px, calc(100vw - 28px)); } +.alerts-dialog .alerts-toolbar { padding: 11px 20px; border-color: var(--dialog-line); background: #fbfcfd; } +.alerts-dialog .alert-form { padding-block: 16px; } +.alerts-dialog .alert-form-grid { grid-template-columns: minmax(0, 1.35fr) 150px 135px; gap: 10px; } +.alerts-dialog .alert-form textarea { min-height: 68px; resize: vertical; } +.alerts-dialog .alert-list-section { padding-bottom: 14px; } +.alerts-dialog .alert-list { max-height: 224px; overflow-y: auto; border-color: var(--dialog-line); } +.alerts-dialog .alert-list > .empty-state { min-height: 94px; } +.alerts-dialog .alert-item { min-height: 72px; padding: 12px 8px; border-color: var(--dialog-line); } + +/* Review assistant: messages own the flexible space and composer stays anchored. */ +.settings-dialog.assistant-dialog { + width: min(760px, calc(100vw - 28px)); + height: min(760px, calc(100dvh - 28px)); + max-height: min(760px, calc(100dvh - 28px)); + overflow: hidden !important; +} +.assistant-dialog[open] { display: flex; flex-direction: column; } +.assistant-dialog .assistant-member-gate { flex: 0 0 auto; margin: 12px 16px 0; } +.assistant-dialog .assistant-member-content { min-height: 0; display: flex; flex: 1 1 auto; flex-direction: column; } +.assistant-dialog .assistant-messages { + min-height: 0; + max-height: none; + flex: 1 1 auto; + padding: 16px 18px; + background: #f7f9fb; +} +.assistant-dialog .assistant-message { max-width: 86%; margin-bottom: 11px; } +.assistant-dialog .assistant-message-content { border-color: #dce3eb; border-radius: 7px; font-size: 12.5px; line-height: 1.62; } +.assistant-dialog .assistant-quick-prompts { flex: 0 0 auto; padding: 10px 16px; border-color: var(--dialog-line); } +.assistant-dialog .assistant-quick-prompts button { min-height: 30px; border-radius: 6px; } +.assistant-dialog .assistant-form { flex: 0 0 auto; padding: 11px 16px 8px; border-color: var(--dialog-line); } +.assistant-dialog .assistant-form textarea { min-height: 66px; max-height: 126px; resize: none; border-radius: 7px; font-size: 12.5px; } +.assistant-dialog .assistant-disclaimer { flex: 0 0 auto; padding: 0 16px 11px; } + +/* Account panels: quiet status treatment and stronger membership hierarchy. */ +.settings-dialog.account-settings-dialog { width: min(720px, calc(100vw - 28px)); } +.account-settings-dialog .connection-status { margin: 12px 20px 0; border-radius: 7px; font-size: 11px; } +.account-settings-dialog .connection-status.connected { display: none; } +.account-settings-dialog .settings-lead { color: #606d7c; font-size: 12px; line-height: 1.6; } +.account-settings-dialog .membership-usage { margin-top: 8px; } +.account-settings-dialog .membership-status-grid { gap: 8px; margin: 14px 0 16px; } +.account-settings-dialog .membership-status-grid > div { + min-height: 68px; + padding: 11px 12px; + border-color: #dfe4ea; + border-radius: 7px; + background: #f8fafc; +} +.account-settings-dialog .membership-status-grid span { font-size: 10px; } +.account-settings-dialog .membership-status-grid strong { margin-top: 7px; font-size: 14px; } +.account-settings-dialog .membership-status-grid > div:first-child { border-color: #e4d5aa; background: #fffbef; } +.account-settings-dialog .membership-status-grid > div:first-child strong { color: #936515; } +.account-settings-dialog .membership-comparison { border-color: #dfe4ea; border-radius: 7px; } +.account-settings-dialog .membership-comparison > div { min-height: 36px; padding: 8px 11px; border-color: #e6eaef; } +.account-settings-dialog .membership-comparison-head { background: #f5f7f9; } +.account-settings-dialog .membership-topup-row { margin-top: 12px; } +.account-settings-dialog .privacy-note { padding: 10px 11px; border: 1px solid #dce8e2; border-radius: 7px; background: #f5faf7; } +.account-settings-dialog .account-birth-form { grid-template-columns: 1.2fr 1fr .72fr; } +.account-settings-dialog .password-form { max-width: 460px; } + +/* Administration is a focused control surface, not a full-width form canvas. */ +.settings-dialog.admin-dialog { width: min(900px, calc(100vw - 28px)); } +.admin-dialog .connection-status { + margin: 12px 20px 0; + padding: 8px 10px; + border-radius: 7px; + color: #667384; + font-size: 11px; +} +.admin-dialog .admin-section-picker { + grid-template-columns: auto minmax(220px, 300px); + gap: 14px; + padding: 12px 20px; + border-bottom: 1px solid var(--dialog-line); + background: #fbfcfd; +} +.admin-dialog .admin-section-picker label { font-size: 11px; } +.admin-dialog .admin-section-picker select { min-height: 36px; border-color: #d4dce5; border-radius: 7px; font-size: 12px; } +.admin-dialog .admin-panel { max-width: 860px; margin-inline: auto; } +.admin-dialog .settings-section + .settings-section { border-top: 1px solid var(--dialog-line); } +.admin-dialog .model-role-selectors { gap: 10px; } +.admin-dialog .model-pool-list { margin-top: 14px; } +.admin-dialog .model-row { border-color: #dfe4ea; border-radius: 7px; background: #fafbfc; } +.admin-dialog .admin-users-list { border-color: var(--dialog-line); } +.admin-dialog .admin-user-row { + grid-template-columns: 150px 90px minmax(0, 1fr); + gap: 12px; + padding: 12px 0; + border-color: var(--dialog-line); +} +.admin-dialog .membership-form { grid-template-columns: 92px minmax(135px, .8fr) minmax(140px, 1fr) auto; } + +/* Editing dialogs share sensible proportions without changing their fields. */ +.settings-dialog.trade-log-dialog { width: min(880px, calc(100vw - 28px)); } +.trade-log-dialog .trade-log-form { padding: 18px 20px 20px; background: #fafbfc; } +.trade-log-dialog .trade-log-form-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; } +.trade-log-dialog .trade-tags-field { grid-column: span 2; } +.trade-log-dialog .trade-log-text-grid { gap: 11px; margin-top: 11px; } +.trade-log-dialog .trade-log-text-grid textarea { min-height: 92px; } +.settings-dialog.watchlist-dialog { width: min(500px, calc(100vw - 28px)); } +.watchlist-dialog .watchlist-editor-form { padding: 16px 18px 18px; } + +@media (max-width: 760px) { + :is(.stock-dialog, .settings-dialog:not(.heaven-reading-dialog), .global-search-dialog) { + width: calc(100vw - 16px); + max-width: none; + max-height: calc(100dvh - 16px); + margin: 8px auto; + border-radius: 8px; + } + :is(.stock-dialog, .settings-dialog:not(.heaven-reading-dialog)) .dialog-header { min-height: 60px; padding: 10px 12px; } + :is(.stock-dialog, .settings-dialog:not(.heaven-reading-dialog)) .dialog-header h2 { font-size: 16px; } + .stock-dialog .dialog-header { align-items: flex-start; } + .stock-dialog .dialog-header-actions { gap: 5px; } + .stock-dialog .dialog-header-actions .button { min-height: 30px; padding-inline: 8px; } + .stock-dialog .dialog-header-actions .button span { display: none; } + .stock-dialog .detail-price-line, + .stock-dialog .stock-chart-section, + .stock-dialog .detail-section { padding-inline: 13px; } + .stock-dialog .price-chart { height: 236px; } + .stock-dialog .moneyflow-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .stock-dialog .moneyflow-grid > div:nth-child(2n) { border-right: 0; } + .stock-dialog .moneyflow-grid > div:nth-last-child(-n + 2) { border-bottom: 0; } + .stock-dialog .detail-grid, + .entity-detail-dialog .detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .stock-dialog .detail-grid > div:nth-child(3n) { border-right: 1px solid var(--dialog-line); } + .stock-dialog .detail-grid > div:nth-child(2n) { border-right: 0; } + .stock-dialog .detail-grid > div:nth-last-child(-n + 3) { border-bottom: 1px solid var(--dialog-line); } + .stock-dialog .detail-grid > div:nth-last-child(-n + 2) { border-bottom: 0; } + .stock-dialog .journal-form.compact-form { grid-template-columns: 1fr; } + .stock-dialog .journal-form.compact-form .dialog-actions { grid-column: auto; } + .alerts-dialog .alert-form-grid { grid-template-columns: 1fr; } + .assistant-dialog { height: calc(100dvh - 16px); } + .assistant-dialog .assistant-messages { padding: 13px; } + .assistant-dialog .assistant-message { max-width: 96%; } + .assistant-dialog .assistant-form { grid-template-columns: 1fr; } + .assistant-dialog .assistant-form-actions { justify-content: flex-end; } + .account-settings-dialog .membership-status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .account-settings-dialog .account-birth-form, + .trade-log-dialog .trade-log-form-grid, + .trade-log-dialog .trade-log-text-grid { grid-template-columns: 1fr; } + .trade-log-dialog .trade-tags-field { grid-column: auto; } + .admin-dialog .admin-section-picker { grid-template-columns: 1fr; gap: 6px; } + .admin-dialog .admin-user-row { grid-template-columns: 1fr auto; } + .admin-dialog .admin-user-row .membership-form { grid-column: 1 / -1; grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 460px) { + .global-search-dialog { margin-top: 8px; } + .global-search-head { grid-template-columns: 20px minmax(0, 1fr) 32px; padding-left: 12px; } + .global-search-head kbd { display: none; } + .global-search-result-code { display: none; } + .stock-dialog .moneyflow-grid, + .stock-dialog .detail-grid, + .entity-detail-dialog .detail-grid { grid-template-columns: 1fr; } + .stock-dialog :is(.moneyflow-grid, .detail-grid) > div { border-right: 0 !important; border-bottom: 1px solid var(--dialog-line) !important; } + .stock-dialog :is(.moneyflow-grid, .detail-grid) > div:last-child { border-bottom: 0 !important; } + .account-settings-dialog .membership-status-grid { grid-template-columns: 1fr; } + .account-settings-dialog .membership-comparison { overflow-x: auto; } + .account-settings-dialog .membership-comparison > div { min-width: 510px; } + .admin-dialog .admin-user-row .membership-form { grid-template-columns: 1fr; } +} + +@media (prefers-reduced-motion: reduce) { + :is(.stock-dialog, .settings-dialog:not(.heaven-reading-dialog))[open] { animation: none; } +} + +/* Pre-stage 19 polish: navigation state, information density and scroll ownership. */ +.overview-strip:not([data-overview-expanded="true"]) .sentiment-gauge { + display: none; +} + +.overview-strip .sentiment-block .metric-label, +.overview-strip .sentiment-block .sentiment-text { + display: block; + margin: 0; + padding: 0; + font-size: 11px; + font-weight: 500; + line-height: 1; + letter-spacing: 0; +} + +.overview-strip .sentiment-block .sentiment-text { align-self: auto; } + +.redesigned-emotion-grid { + grid-template-columns: minmax(0, 1fr) 340px; + align-items: start; +} + +.redesigned-sentiment-view .sentiment-analysis-main, +.redesigned-sentiment-view .sentiment-analysis-rail { + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.redesigned-sentiment-view .sentiment-chart-shell { + height: 350px; + padding-top: 12px; +} + +.redesigned-sentiment-view .sentiment-chart-shell canvas { + height: 332px; +} + +.redesigned-sentiment-view .sentiment-stage-guide { + margin: 0; +} + +.redesigned-sentiment-view .sentiment-stage-guide-grid { + min-height: 0; +} + +.redesigned-sentiment-view .sentiment-stage-guide-grid article:not(.current), +.redesigned-sentiment-view .sentiment-stage-guide-grid article[hidden] { + display: none; +} + +.redesigned-sentiment-view .sentiment-stage-guide-grid article.current { + min-height: 50px; + height: 50px; + background: #fff; +} + +/* Shared pool-table geometry. Data remains dense, while identity and context stay scannable. */ +:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) { + width: 100%; + table-layout: fixed; +} + +#limitTable, +#brokenTable, +#downTable, +#yesterdayTable { min-width: 100%; } + +#limitTable .pool-col-index { width: 3%; } +#limitTable .pool-col-standard { width: 6.4667%; } +#limitTable .pool-col-reason { width: 12.933%; } +#brokenTable .pool-col-index { width: 3.2%; } +#brokenTable .pool-col-standard { width: 8.0667%; } +#brokenTable .pool-col-reason { width: 16.133%; } +#downTable .pool-col-index { width: 3.8%; } +#downTable .pool-col-standard { width: 9.62%; } +#downTable .pool-col-reason { width: 19.24%; } +#yesterdayTable .pool-col-index { width: 4%; } +#yesterdayTable .pool-col-standard { width: 10.6667%; } +#yesterdayTable .pool-col-reason { width: 21.333%; } + +:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) .row-number { + padding-right: 8px; + padding-left: 8px; + text-align: center; +} + +:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) .stock-code-column, +:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) .pool-code-cell { + font-variant-numeric: tabular-nums; + text-align: left; +} + +:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) .pool-code-cell { + color: var(--r2-sub); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 11.5px; +} + +:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) .pool-name-cell { + overflow: hidden; + color: var(--r2-ink); + font-size: 13px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) .reason-column, +:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) .pool-reason-cell { + text-align: left; +} + +:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) .pool-reason-cell { + overflow: hidden; + color: var(--r2-sub); + font-size: 12px; + line-height: 1.5; + text-overflow: ellipsis; + white-space: nowrap; +} + +:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) tbody tr { + transition: background-color 150ms ease; +} + +#screenerView .screener-result-source { + max-width: min(48vw, 440px); + overflow: hidden; + padding: 3px 8px; + border: 1px solid #d9e2ef; + border-radius: 5px; + background: #f7f9fc; + color: var(--r2-sub); + font-size: 11px; + font-weight: 600; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* The Dragon-Tiger page stays still; only the selected trader's operations scroll. */ +@media (min-width: 721px) { + body[data-active-view="dragonView"] .dragon-daily-content-v2 { + min-height: 0; + display: grid; + grid-template-rows: auto auto auto minmax(150px, 1fr) auto; + overflow: hidden; + } + + body[data-active-view="dragonView"] #dragonView .dragon-card-stage-v2 { + flex: 0 0 auto; + } + + body[data-active-view="dragonView"] #dragonView .dragon-trader-detail-v2 { + min-height: 0; + display: flex; + flex-direction: column; + } + + body[data-active-view="dragonView"] #dragonView .dragon-detail-header { + flex: 0 0 auto; + } + + body[data-active-view="dragonView"] #dragonView .dragon-trader-detail .trader-operations { + min-height: 0; + flex: 1 1 auto; + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + } + + body[data-active-view="dragonView"] #dragonView .dragon-unclassified-v2 { + max-height: 180px; + overflow: auto; + } + + :is( + .redesigned-auction-view, + .redesigned-theme-view, + .redesigned-popularity-view, + .redesigned-dragon-view + ) { + width: min(100%, 2200px); + margin: 14px auto 0; + padding: 0; + } +} + +@media (min-width: 901px) { + .workspace-view:is( + #auctionView, + #themeLibraryView, + #popularityView, + #dragonView + ) { + width: auto; + margin: 14px 0 0; + padding: 14px 16px 22px; + overflow: visible; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + } +} + +#dragonView .dragon-detail-header p { + font-size: 13px; + line-height: 1.55; +} + +@media (max-width: 960px) { + .redesigned-emotion-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 1200px) { + #limitTable { min-width: 1080px; } + #brokenTable { min-width: 920px; } + #downTable { min-width: 780px; } + #yesterdayTable { min-width: 720px; } +} + +@media (max-width: 720px) { + :is(#limitTable, #brokenTable, #downTable, #yesterdayTable) .pool-reason-cell { + white-space: normal; + } +} diff --git a/static/renovation.css b/static/renovation.css new file mode 100644 index 0000000..4df2cd3 --- /dev/null +++ b/static/renovation.css @@ -0,0 +1,1636 @@ +/* Xiaobai 2026 visual renovation + Structural layer built on semantic tokens. Page-specific character (notably Wentian) + remains owned by the underlying feature styles. */ + +:root { + /* primitive */ + --xb-gray-25: #fcfcfd; + --xb-gray-50: #f8f9fb; + --xb-gray-100: #f2f4f7; + --xb-gray-200: #e5e8ee; + --xb-gray-300: #d4d9e2; + --xb-gray-500: #697386; + --xb-gray-700: #344054; + --xb-gray-900: #172033; + --xb-blue-50: #eef4ff; + --xb-blue-100: #dce8ff; + --xb-blue-500: #356ae6; + --xb-blue-600: #2858c7; + --xb-red-50: #fff1f0; + --xb-red-500: #df493b; + --xb-green-50: #ecf9f1; + --xb-green-500: #179b55; + --xb-amber-50: #fff7e8; + --xb-amber-500: #d7820b; + + /* semantic */ + --surface-canvas: var(--xb-gray-100); + --surface-raised: #ffffff; + --surface-subtle: var(--xb-gray-50); + --surface-selected: var(--xb-blue-50); + --text-primary: var(--xb-gray-900); + --text-secondary: var(--xb-gray-500); + --text-tertiary: #98a1b2; + --border: var(--xb-gray-200); + --border-strong: var(--xb-gray-300); + --primary: var(--xb-blue-500); + --primary-hover: var(--xb-blue-600); + --danger: var(--xb-red-500); + --success: var(--xb-green-500); + --warning: var(--xb-amber-500); + --radius-sm: 5px; + --radius-md: 8px; + --radius-lg: 10px; + --shadow-xs: 0 1px 2px rgba(16, 24, 40, .04); + --shadow-sm: 0 3px 10px rgba(16, 24, 40, .055); + --shadow-float: 0 14px 38px rgba(16, 24, 40, .14); + --duration-fast: 150ms; + --duration-normal: 220ms; + + /* component */ + --card-bg: var(--surface-raised); + --card-border: var(--border); + --card-radius: var(--radius-lg); + --card-shadow: var(--shadow-xs); + --control-height: 34px; + --page-gap: 12px; +} + +html { background: var(--surface-canvas); } + +body { + grid-template-columns: 204px minmax(0, 1fr); + grid-template-rows: 56px minmax(0, 1fr) 28px; + background: var(--surface-canvas); + color: var(--text-primary); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 13px; + letter-spacing: 0; +} + +button, input, select, textarea { font: inherit; letter-spacing: 0; } + +/* Shell: preserve the app's own navigation model, but tighten its hierarchy. */ +.app-header { + min-height: 56px; + height: 56px; + grid-template-columns: 204px minmax(0, 1fr); + padding: 0 14px 0 16px; + border-bottom: 1px solid var(--border); + background: rgba(255, 255, 255, .97); + box-shadow: none; +} + +.brand-block { min-width: 186px; gap: 10px; } +.brand-mark { width: 30px; height: 30px; } +.brand-block h1 { font-size: 15px; font-weight: 760; color: var(--text-primary); } +.market-tape { gap: 18px; color: var(--text-secondary); font-size: 12px; } +.market-item strong { color: var(--text-primary); font-variant-numeric: tabular-nums; } +.market-item.up strong { color: var(--danger); } +.market-item.down strong { color: var(--success); } + +.header-actions { gap: 7px; } +.header-date-group, +.icon-button, +.button, +.date-input { + min-height: var(--control-height); + border-color: var(--border-strong); + border-radius: var(--radius-md); + box-shadow: none; +} + +.button { + padding: 0 12px; + background: var(--surface-raised); + color: var(--xb-gray-700); + font-size: 12px; + font-weight: 620; + transition: border-color var(--duration-fast), color var(--duration-fast), background var(--duration-fast), transform var(--duration-fast); +} +.button:hover { border-color: #b8c7e8; color: var(--primary); background: var(--xb-gray-25); } +.button:active { transform: translateY(1px); } +.button.primary { border-color: var(--primary); background: var(--primary); color: #fff; } +.button.primary:hover { border-color: var(--primary-hover); background: var(--primary-hover); color: #fff; } + +.module-nav { + top: 56px; + width: 204px; + height: calc(100vh - 56px); + padding: 9px 8px 10px; + border-right: 1px solid var(--border); + background: var(--surface-raised); + box-shadow: none; +} + +.nav-brand { padding: 5px 9px 8px; color: var(--text-tertiary); font-size: 10px; font-weight: 700; text-transform: uppercase; } +.nav-group { gap: 1px; margin: 0 0 8px; } +.nav-group-label { padding: 9px 9px 5px; color: var(--text-tertiary); font-size: 11px; font-weight: 650; } +.module-tab { + min-height: 34px; + padding: 0 10px; + border-radius: 6px; + color: #4b5565; + font-size: 12.5px; + gap: 9px; + transition: color var(--duration-fast), background var(--duration-fast); +} +.module-tab .lucide { width: 16px; height: 16px; color: #8d96a7; } +.module-tab:hover { background: var(--xb-gray-50); color: var(--text-primary); } +.module-tab.active { background: var(--xb-blue-50); color: var(--primary); font-weight: 700; box-shadow: none; } +.module-tab.active .lucide { color: var(--primary); } +.sidebar-collapse-button { margin-top: auto; border-top: 1px solid var(--border); color: var(--text-secondary); } + +.app-main { + width: auto; + min-width: 0; + margin-left: 0; + padding: 0; + background: var(--surface-canvas); +} + +body.sidebar-collapsed { grid-template-columns: 68px minmax(0, 1fr); } +body.sidebar-collapsed .app-main { margin-left: 0; } + +/* Market summary: one calm strip by default, full metric card only on demand. */ +.overview-strip { + position: relative; + display: grid; + grid-template-columns: minmax(178px, 1.25fr) repeat(5, minmax(74px, .55fr)) minmax(180px, 1fr) 104px; + min-height: 44px; + margin: 0; + padding: 0 14px; + gap: 0; + border: 0; + border-bottom: 1px solid var(--border); + border-radius: 0; + background: var(--surface-raised); + box-shadow: none; +} + +.overview-strip .sentiment-block, +.overview-strip .metric { + min-width: 0; + min-height: 44px; + display: flex; + align-items: center; + gap: 7px; + padding: 0 12px; + border: 0; + border-right: 1px solid var(--xb-gray-100); + background: transparent; +} + +.overview-strip .sentiment-block { padding-left: 0; } +.overview-strip .sentiment-gauge { width: 26px; height: 26px; flex: 0 0 26px; border-width: 2px; font-size: 10px; } +.overview-strip .sentiment-text { font-size: 12px; white-space: nowrap; } +.overview-strip .metric-label { color: var(--text-tertiary); font-size: 10.5px; white-space: nowrap; } +.overview-strip .metric-value { color: var(--text-primary); font-size: 13px; font-weight: 720; white-space: nowrap; font-variant-numeric: tabular-nums; } +.overview-strip .metric-value.small { overflow: hidden; font-size: 11.5px; text-overflow: ellipsis; } +.overview-strip .metric-value.up { color: var(--danger); } +.overview-strip .metric-value.down { color: var(--success); } +.overview-strip .metric-value.warning { color: var(--warning); } + +.overview-toggle { + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + padding: 0; + border: 0; + background: transparent; + color: var(--primary); + font-size: 11.5px; + cursor: pointer; +} +.overview-toggle .lucide { width: 14px; height: 14px; } + +.overview-strip[data-overview-expanded="true"] { + grid-template-columns: minmax(200px, 1.2fr) repeat(5, minmax(90px, .62fr)) minmax(190px, 1fr) 104px; + min-height: 76px; +} +.overview-strip[data-overview-expanded="true"] .sentiment-block, +.overview-strip[data-overview-expanded="true"] .metric { min-height: 76px; align-items: flex-start; justify-content: center; flex-direction: column; gap: 4px; } +.overview-strip[data-overview-expanded="true"] .sentiment-block { flex-direction: row; justify-content: flex-start; align-items: center; } +.overview-strip[data-overview-expanded="true"] .sentiment-gauge { width: 48px; height: 48px; flex-basis: 48px; font-size: 13px; } +.overview-strip[data-overview-expanded="true"] .metric-value { font-size: 18px; } + +/* Page frame and shared information architecture. */ +.workspace-view, +body[data-active-view="screenerView"] .workspace-view, +body[data-active-view="mentorView"] .workspace-view, +body[data-active-view="reviewWorkspaceView"] .workspace-view { + width: auto; + max-width: none; + margin: 0; + padding: 14px 16px 24px; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.workspace-view.active-view { display: block; } +.workspace-view.active-view.view-entering { animation: xb-view-enter 240ms ease both; } +@keyframes xb-view-enter { from { opacity: .35; transform: translateY(3px); } to { opacity: 1; transform: none; } } + +.section-toolbar { + min-height: 36px; + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 10px; + padding: 0; + border: 0; + background: transparent; +} +.section-title-group { display: flex; align-items: baseline; gap: 9px; min-width: 0; } +.section-title-group h2 { margin: 0; color: var(--text-primary); font-size: 17px; line-height: 1.25; font-weight: 780; } +.section-subtitle { color: var(--text-tertiary); font-size: 11.5px; } +.count-badge { min-height: 22px; padding: 2px 7px; border-radius: 5px; background: var(--xb-gray-200); color: var(--text-secondary); font-size: 11px; } +.toolbar-controls { margin-left: auto; gap: 7px; } + +.page-headline-stats { margin-left: auto; display: flex; align-items: center; gap: 6px; } +.page-headline-stats span { min-height: 27px; display: inline-flex; align-items: center; gap: 4px; padding: 0 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-raised); color: var(--text-secondary); font-size: 11px; } +.page-headline-stats strong { color: var(--text-primary); font-variant-numeric: tabular-nums; } + +.table-frame, +.phase-table-frame, +.sentiment-chart-card, +.sentiment-composition-card, +.rotation-history-panel, +.performance-panel, +.theme-library-layout, +.popularity-layout, +.auction-workspace, +.review-panel, +.review-section, +.dragon-workspace { + border: 1px solid var(--card-border); + border-radius: var(--card-radius); + background: var(--card-bg); + box-shadow: var(--card-shadow); +} + +.table-frame { overflow: hidden; } +.data-table { width: 100%; border-collapse: collapse; font-size: 12.5px; } +.data-table thead th { + height: 36px; + padding: 0 11px; + border-bottom: 1px solid var(--border); + background: var(--surface-subtle); + color: var(--text-secondary); + font-size: 11.5px; + font-weight: 650; + white-space: nowrap; +} +.data-table tbody td { height: 42px; padding: 6px 11px; border-bottom: 1px solid #edf0f4; color: var(--xb-gray-700); } +.data-table tbody tr:last-child td { border-bottom: 0; } +.data-table tbody tr { transition: background var(--duration-fast); } +.data-table tbody tr:hover td { background: #f8faff; } +.data-table .stock-name { color: var(--text-primary); font-weight: 700; } +.number { font-variant-numeric: tabular-nums; } + +.segmented { padding: 2px; border: 0; border-radius: 7px; background: #eceff4; } +.segment { min-height: 28px; padding: 0 10px; border: 0; border-radius: 5px; color: var(--text-secondary); font-size: 11.5px; } +.segment.active { background: var(--surface-raised); color: var(--text-primary); box-shadow: 0 1px 2px rgba(16, 24, 40, .09); } + +.search-field, +.curated-search, +.form-field input, +.form-field select, +.form-field textarea { + border-color: var(--border-strong); + border-radius: var(--radius-md); + background: var(--surface-raised); + box-shadow: none; +} +.search-field:focus-within, +.curated-search:focus-within, +.form-field input:focus, +.form-field select:focus, +.form-field textarea:focus { border-color: #8eacef; box-shadow: 0 0 0 3px rgba(53, 106, 230, .1); } + +/* Market ladder: the tier bands are the main actor; interpretation stays secondary. */ +.ladder-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 294px; gap: 12px; align-items: start; } +.ladder-board { display: flex; flex-direction: column; gap: 0; padding: 0; border: 1px solid var(--border); border-radius: var(--card-radius); background: var(--surface-raised); box-shadow: var(--card-shadow); overflow: hidden; } +.ladder-step { + --ladder-accent: var(--primary); + --ladder-tint: var(--xb-blue-50); + width: 100%; + min-height: 92px; + display: grid; + grid-template-columns: 118px minmax(0, 1fr) auto; + align-items: stretch; + padding: 0; + border: 0; + border-bottom: 1px solid var(--border); + border-radius: 0; + background: var(--surface-raised); + box-shadow: none; +} +.ladder-step:last-child { border-bottom: 0; } +.ladder-step[data-ladder-level-card="1"] { --ladder-accent: #336ae8; --ladder-tint: #eef4ff; } +.ladder-step[data-ladder-level-card="2"] { --ladder-accent: #159953; --ladder-tint: #edf9f2; } +.ladder-step[data-ladder-level-card="3"] { --ladder-accent: #d67b05; --ladder-tint: #fff7e8; } +.ladder-step[data-ladder-level-card="4"] { --ladder-accent: #db4d3f; --ladder-tint: #fff1f0; } +.ladder-step[data-ladder-level-card="5"], +.ladder-step[data-ladder-level-card="6"], +.ladder-step[data-ladder-level-card="7"] { --ladder-accent: #9a4c76; --ladder-tint: #fbf1f7; } + +.ladder-step-header { + min-width: 0; + display: block; + padding: 15px 13px; + border: 0; + border-right: 1px solid var(--border); + background: var(--ladder-tint); +} +.ladder-level-dot { width: 8px; height: 8px; display: inline-block; margin-right: 6px; border-radius: 50%; background: var(--ladder-accent); vertical-align: 1px; } +.ladder-step-header > div { display: inline; } +.ladder-step-header strong { display: inline; color: var(--ladder-accent); font-size: 15px; font-weight: 800; } +.ladder-step-header small { display: block; margin: 5px 0 0 15px; color: var(--text-secondary); font-size: 11px; } +.ladder-step-header em { display: block; margin: 8px 0 0 15px; color: var(--text-secondary); font-size: 10.5px; font-style: normal; line-height: 1.45; } + +.ladder-step-stocks { min-width: 0; display: flex; align-content: center; align-items: center; flex-wrap: wrap; gap: 7px; padding: 12px 14px; } +.ladder-step .ladder-stock { + min-width: 196px; + max-width: 240px; + min-height: 58px; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; + gap: 6px; + padding: 7px 10px; + border: 1px solid #dfe4ec; + border-radius: 7px; + background: var(--surface-raised); + color: var(--text-primary); + text-align: left; + transition: border-color var(--duration-fast), box-shadow var(--duration-fast), transform var(--duration-fast); +} +.ladder-step .ladder-stock:hover { border-color: #aebfe5; background: #fff; color: var(--text-primary); box-shadow: 0 5px 14px rgba(31, 55, 100, .09); transform: translateY(-1px); } +.ladder-stock-title { display: flex; align-items: center; gap: 5px; min-width: 0; } +.ladder-stock-title strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } +.ladder-stock-title .stock-code { color: var(--text-tertiary); font-size: 10px; } +.ladder-stock-tag { margin-left: auto; padding: 2px 5px; border-radius: 4px; background: var(--xb-red-50); color: var(--danger); font-size: 9.5px; font-style: normal; white-space: nowrap; } +.ladder-stock-tag.broken { background: var(--xb-amber-50); color: var(--warning); } +.ladder-stock-meta { display: flex; align-items: center; gap: 6px; min-width: 0; color: var(--text-tertiary); font-size: 9.5px; } +.ladder-stock-meta b { max-width: 78px; overflow: hidden; padding: 2px 5px; border-radius: 4px; background: var(--xb-blue-50); color: var(--primary); font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.ladder-stock-meta small { white-space: nowrap; } +.ladder-more { align-self: center; margin-right: 12px; padding: 7px 8px; border: 1px dashed var(--border-strong); border-radius: 6px; color: var(--text-secondary); font-size: 11px; white-space: nowrap; } +.ladder-gap { min-height: 68px; background: repeating-linear-gradient(135deg, #fff, #fff 9px, #fafbfc 9px, #fafbfc 18px); } +.ladder-gap .ladder-step-header { background: rgba(248, 249, 251, .86); } +.ladder-gap-note { color: var(--text-tertiary); font-size: 11.5px; } + +.ladder-insights { display: grid; gap: 10px; } +.ladder-insight-card { border: 1px solid var(--border); border-radius: var(--card-radius); background: var(--surface-raised); box-shadow: var(--card-shadow); overflow: hidden; } +.ladder-insight-card > header { min-height: 40px; display: flex; align-items: center; padding: 0 13px; border-bottom: 1px solid var(--border); } +.ladder-insight-card > header h3 { margin: 0; font-size: 13.5px; } +.ladder-insight-card > header span { margin-left: auto; padding: 2px 6px; border-radius: 4px; background: var(--xb-gray-100); color: var(--text-secondary); font-size: 10px; } +.ladder-insight-card > p { margin: 0; padding: 0 13px 12px; color: var(--text-secondary); font-size: 10.5px; line-height: 1.65; } +.ladder-apex { display: flex; flex-direction: column; gap: 5px; padding: 14px 13px 9px; } +.ladder-apex strong { color: var(--danger); font-size: 25px; } +.ladder-apex span { color: var(--text-primary); font-size: 12px; font-weight: 650; line-height: 1.55; } +.ladder-structure-bars { display: grid; gap: 7px; padding: 12px 13px; } +.ladder-structure-row { display: grid; grid-template-columns: 40px minmax(0, 1fr) 38px; align-items: center; gap: 7px; font-size: 10.5px; } +.ladder-structure-row > span { color: var(--text-secondary); text-align: right; } +.ladder-structure-row > i, +.ladder-rate-list i { height: 8px; overflow: hidden; border-radius: 4px; background: var(--xb-gray-100); } +.ladder-structure-row > i b { height: 100%; display: block; border-radius: inherit; background: var(--primary); } +.ladder-structure-row:nth-child(2) > i b { background: var(--danger); } +.ladder-structure-row:nth-child(3) > i b { background: var(--warning); } +.ladder-structure-row:nth-child(4) > i b { background: var(--success); } +.ladder-structure-row.is-gap i { background: repeating-linear-gradient(135deg, #e8ebf0, #e8ebf0 4px, #f6f7f9 4px, #f6f7f9 8px); } +.ladder-structure-row > strong { font-size: 10.5px; } +.ladder-rate-list { display: grid; gap: 9px; padding: 12px 13px; } +.ladder-rate-list > div { display: grid; grid-template-columns: 72px minmax(0, 1fr) 42px; align-items: center; gap: 7px; color: var(--text-secondary); font-size: 10px; } +.ladder-rate-list i b { height: 100%; display: block; border-radius: inherit; background: var(--primary); } +.ladder-rate-list strong { color: var(--text-primary); text-align: right; } + +/* Rotation: nine-day heat matrix and cross-day tracking. */ +.rotation-history-panel { overflow: hidden; padding: 0; } +.rotation-history-heading { min-height: 44px; display: flex; align-items: center; padding: 0 13px; border-bottom: 1px solid var(--border); } +.rotation-history-heading h3 { margin: 0; font-size: 13.5px; } +.rotation-history-heading span { margin-left: 7px; color: var(--text-tertiary); font-size: 11px; } +.rotation-legend { min-height: 34px; display: flex; align-items: center; gap: 13px; padding: 0 13px; border-bottom: 1px solid #edf0f4; color: var(--text-secondary); font-size: 10.5px; } +.rotation-legend span { display: inline-flex; align-items: center; gap: 4px; } +.rotation-legend small { margin-left: auto; color: var(--text-tertiary); } +.rotation-swatch { width: 14px; height: 9px; border-radius: 2px; background: rgba(53, 106, 230, .12); } +.rotation-swatch.warm { background: rgba(53, 106, 230, .30); } +.rotation-swatch.strong { background: rgba(53, 106, 230, .56); } + +.rotation-tracker { min-height: 48px; display: flex; align-items: center; gap: 20px; padding: 7px 13px; border-bottom: 1px solid #cbd9f7; background: var(--xb-blue-50); } +.rotation-tracker[hidden] { display: none; } +.rotation-tracker-copy { min-width: 210px; display: flex; flex-direction: column; gap: 3px; } +.rotation-tracker-copy strong { color: var(--primary); font-size: 13px; } +.rotation-tracker-copy span { color: #526b9f; font-size: 10.5px; } +.rotation-tracker-spark { height: 32px; flex: 1; display: grid; grid-template-columns: repeat(9, minmax(20px, 1fr)); align-items: end; gap: 4px; } +.rotation-tracker-spark > span { height: 100%; display: flex; align-items: end; justify-content: center; position: relative; } +.rotation-tracker-spark i { width: min(18px, 70%); height: var(--spark-height); min-height: 5px; display: block; border-radius: 3px 3px 0 0; background: #88aaf1; } +.rotation-tracker-spark small { position: absolute; top: -1px; color: #4664a0; font-size: 8px; } +.rotation-tracker-spark .missing i { height: 7px; border: 1px dashed #b7c5e0; border-bottom: 0; background: transparent; } + +.rotation-history { width: 100%; min-height: 0; display: grid; grid-template-columns: repeat(9, minmax(112px, 1fr)); gap: 0; padding: 0; overflow-x: auto; } +.rotation-day { min-width: 112px; border: 0; border-right: 1px solid #edf0f4; border-radius: 0; background: #fff; box-shadow: none; } +.rotation-day:last-child { border-right: 0; } +.rotation-day > header { min-height: 42px; padding: 7px 9px; border-bottom: 1px solid #edf0f4; background: var(--surface-subtle); } +.rotation-day > header time { display: block; color: var(--text-primary); font-size: 11px; font-weight: 700; } +.rotation-day > header span { display: block; margin-top: 2px; color: var(--text-tertiary); font-size: 9.5px; } +.rotation-day.latest-day > header { background: var(--xb-blue-50); } +.rotation-day.latest-day > header time { color: var(--primary); } +.rotation-day-sectors { display: grid; gap: 0; padding: 0; } +.rotation-sector-chip { + min-width: 0; + min-height: 42px; + display: grid; + grid-template-columns: 17px minmax(0, 1fr); + align-items: center; + gap: 5px; + padding: 5px 8px; + border: 0; + border-bottom: 1px dashed rgba(135, 149, 173, .22); + border-radius: 0; + background: rgba(53, 106, 230, calc(.04 + var(--rotation-heat) * .46)); + text-align: left; + transition: opacity var(--duration-fast), filter var(--duration-fast), box-shadow var(--duration-fast); +} +.rotation-sector-chip:hover { filter: saturate(1.08) brightness(.97); } +.rotation-sector-chip strong { min-width: 0; overflow: hidden; color: var(--text-primary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.rotation-sector-chip small { grid-column: 2; margin-top: -4px; color: #5d6b82; font-size: 8.5px; white-space: nowrap; } +.rotation-sector-chip small b { color: var(--text-primary); } +.rotation-rank { width: 16px; height: 16px; display: grid; place-items: center; border-radius: 4px; background: rgba(255, 255, 255, .72); color: var(--text-secondary); font-size: 8.5px; font-weight: 750; } +.rotation-rank.rank-1 { background: #df493b; color: #fff; } +.rotation-rank.rank-2 { background: #f07754; color: #fff; } +.rotation-rank.rank-3 { background: #e8a226; color: #fff; } +.rotation-history.tracking .rotation-sector-chip:not(.selected) { opacity: .20; } +.rotation-history.tracking .rotation-sector-chip.selected { opacity: 1; box-shadow: inset 0 0 0 2px var(--primary); } +.rotation-detail-toolbar { margin-top: 13px; } +.rotation-detail-row { cursor: pointer; } +.rotation-detail-row.selected td { background: var(--xb-blue-50) !important; } + +/* Screener: method tabs lead; working state and results stay in the first viewport. */ +#screenerView { padding-top: 12px; } +.screener-page-heading { margin-bottom: -36px; pointer-events: none; } +.screener-mode-tabs { min-height: 38px; justify-content: flex-end; gap: 2px; margin: 0 0 12px; padding: 0; border: 0; border-bottom: 1px solid var(--border); background: transparent; } +.screener-mode-tabs button { min-height: 38px; padding: 0 14px; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent; color: var(--text-secondary); font-size: 12px; } +.screener-mode-tabs button:hover { background: transparent; color: var(--text-primary); } +.screener-mode-tabs button.active { border-bottom-color: var(--primary); background: transparent; color: var(--primary); } +#screenerView .screener-strategy-view { display: grid; gap: 10px; padding: 0; background: transparent; } +.screener-stepper { min-height: 56px; margin: 0; padding: 8px 14px; border: 1px solid var(--border); border-radius: var(--card-radius); background: var(--surface-raised); box-shadow: var(--card-shadow); } +.screener-step { min-width: 0; } +.screener-step .step-marker { width: 22px; height: 22px; font-size: 10px; } +.screener-step strong { font-size: 11.5px; } +.screener-step small { font-size: 9.5px; } +.screener-overview-grid { gap: 10px; margin: 0; } +.screener-overview-card { min-height: 0; padding: 14px; border: 1px solid var(--border); border-radius: var(--card-radius); background: var(--surface-raised); box-shadow: var(--card-shadow); } +.screener-runbar { margin: 0; padding: 9px 12px; border: 1px solid var(--border); border-radius: var(--card-radius); background: var(--surface-raised); box-shadow: var(--card-shadow); } +.screener-backtest-strip { margin: 0; border: 1px solid #ecdcb8; border-radius: var(--card-radius); } + +.curated-screener-panel { height: auto; min-height: 0; display: grid; grid-template-columns: 1fr; gap: 12px; margin: 0; padding: 0; border: 0; background: transparent; box-shadow: none; overflow: visible; } +.curated-library-pane { min-width: 0; padding: 13px; border: 1px solid var(--border); border-radius: var(--card-radius); background: var(--surface-raised); box-shadow: var(--card-shadow); } +.curated-library-heading { min-height: 34px; align-items: center; } +.curated-library-heading > div { display: flex; align-items: baseline; gap: 8px; } +.curated-library-heading span { color: var(--text-tertiary); font-size: 10px; } +.curated-library-heading h3 { margin: 0; font-size: 14px; } +.curated-library-controls { display: flex; align-items: center; gap: 10px; margin: 9px 0 12px; } +.curated-search { width: 220px; height: 34px; flex: 0 0 220px; margin: 0; } +.curated-category-filters { flex: 1; margin: 0; } +.curated-strategy-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; margin: 0; max-height: none; overflow: visible; } +.curated-strategy-card { + width: 100%; + min-height: 176px; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 9px; + padding: 13px; + border: 1px solid var(--border); + border-radius: 8px; + background: #fff; + color: var(--text-primary); + text-align: left; + transition: border-color var(--duration-normal), box-shadow var(--duration-normal), transform var(--duration-normal); +} +.curated-strategy-card:hover { border-color: #adbee7; box-shadow: 0 7px 19px rgba(35, 66, 124, .09); transform: translateY(-2px); } +.curated-strategy-card.active { border-color: #8aa8eb; background: #fbfdff; box-shadow: inset 0 3px var(--primary), 0 6px 16px rgba(35, 66, 124, .08); } +.curated-card-head { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 8px; } +.curated-strategy-rank { width: 27px; height: 27px; display: grid; place-items: center; border-radius: 5px; background: var(--xb-gray-100); color: var(--text-secondary); font-size: 10px; font-style: normal; } +.curated-card-head span strong, +.curated-card-head span small { display: block; } +.curated-card-head span strong { font-size: 13px; } +.curated-card-head span small { margin-top: 2px; color: var(--text-tertiary); font-size: 9.5px; } +.curated-card-tags { display: flex; flex-wrap: wrap; gap: 4px; } +.curated-card-tags em { padding: 2px 6px; border-radius: 4px; background: var(--xb-blue-50); color: var(--primary); font-size: 9.5px; font-style: normal; } +.curated-card-tags em:nth-child(2) { background: var(--xb-gray-100); color: var(--text-secondary); } +.curated-card-tags em:nth-child(3) { background: var(--xb-amber-50); color: var(--warning); } +.curated-card-description { flex: 1; color: var(--text-secondary); font-size: 11px; line-height: 1.65; } +.curated-card-foot { display: flex; align-items: center; padding-top: 8px; border-top: 1px solid #edf0f4; } +.curated-card-foot small { color: var(--text-tertiary); font-size: 9.5px; } +.curated-card-foot strong { margin-left: auto; display: inline-flex; align-items: center; gap: 3px; color: var(--primary); font-size: 10.5px; } +.curated-card-foot .lucide { width: 12px; height: 12px; } +.curated-detail-pane { padding: 16px 18px; border: 1px solid var(--border); border-radius: var(--card-radius); background: var(--surface-raised); box-shadow: var(--card-shadow); } +.curated-detail-header { padding-bottom: 13px; } +.curated-detail-grid { padding: 14px 0; } +.curated-execution-bar { min-height: 54px; } + +.quant-screener-panel { height: auto; min-height: 0; margin: 0; gap: 10px; border: 0; background: transparent; box-shadow: none; } +.quant-builder-pane, +.quant-preview-pane { border: 1px solid var(--border); border-radius: var(--card-radius); background: var(--surface-raised); box-shadow: var(--card-shadow); overflow: hidden; } + +/* Secondary pages: consistent card rhythm without erasing feature identity. */ +.sentiment-cycle-hero, +.sentiment-stage-strip, +.auction-expectation-panel, +.auction-theme-panel, +.auction-volume-panel, +.auction-news-panel, +.theme-directory-panel, +.theme-detail-panel, +.popularity-main-panel, +.popularity-side-panel, +.mentor-directory-panel, +.mentor-chat-panel, +.review-summary-panel, +.review-content-panel { + border-color: var(--border); + border-radius: var(--card-radius); + background: var(--surface-raised); + box-shadow: var(--card-shadow); +} + +.inline-notice { border-radius: 7px; font-size: 11.5px; } +.empty-state { color: var(--text-tertiary); font-size: 11.5px; } +.dialog-card, +.settings-card, +.stock-detail-card, +.entity-detail-card { + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: var(--shadow-float); +} + +/* Page architecture: independent modules, deliberate whitespace, one primary task. */ +.screener-page-bar { + min-height: 46px; + display: flex; + align-items: flex-end; + gap: 20px; + margin-bottom: 16px; + border-bottom: 1px solid var(--border); +} +.screener-page-bar .screener-page-heading { min-height: 45px; flex: 1; margin: 0; padding-bottom: 10px; pointer-events: auto; } +.screener-page-bar .screener-mode-tabs { min-height: 45px; flex: 0 0 auto; margin: 0; border: 0; } +.screener-page-bar .screener-mode-tabs button { min-height: 45px; } + +#screenerView .screener-strategy-view { gap: 14px; } +#screenerView .screener-overview-grid { grid-template-columns: minmax(0, 1.08fr) minmax(0, .92fr); gap: 14px; } +#screenerView .screener-overview-card { padding: 0; overflow: hidden; } +#screenerView .screener-card-heading { min-height: 42px; padding: 0 16px; border-bottom: 1px solid var(--border); } +#screenerView .screener-card-heading h3 { font-size: 14px; } +#screenerView .screener-regime-body { + display: grid; + grid-template-columns: 138px 128px minmax(0, 1fr); + align-items: stretch; + gap: 0; + padding: 0; +} +#screenerView .regime-summary, +#screenerView .regime-temperature { min-height: 112px; display: flex; flex-direction: column; justify-content: center; padding: 16px; border-right: 1px solid var(--border); } +#screenerView .regime-summary strong { color: var(--danger); font-size: 25px; line-height: 1.1; } +#screenerView .regime-summary span { margin-top: 7px; color: var(--text-secondary); font-size: 10.5px; } +#screenerView .regime-temperature span { color: var(--text-secondary); font-size: 10.5px; } +#screenerView .regime-temperature strong { margin-top: 4px; color: var(--text-primary); font-size: 25px; font-variant-numeric: tabular-nums; } +#screenerView .regime-temperature small { margin-top: 5px; color: var(--success); font-size: 10.5px; } +#screenerView .regime-evidence { min-width: 0; min-height: 112px; display: flex; flex-direction: column; justify-content: center; gap: 5px; padding: 14px 16px; border: 0; border-radius: 0; background: var(--xb-amber-50); } +#screenerView .regime-evidence strong { color: #925806; font-size: 12px; line-height: 1.55; } +#screenerView .regime-evidence div { max-height: none; overflow: visible; color: #a26a18; font-size: 10.5px; line-height: 1.55; } +#screenerView .regime-selector { min-height: 54px; display: flex; align-items: center; gap: 5px; padding: 9px 16px; border-top: 1px solid var(--border); background: var(--surface-subtle); } +#screenerView .regime-selector::before { content: "阶段校准"; margin-right: 4px; color: var(--text-tertiary); font-size: 10px; } +#screenerView .regime-selector button { min-height: 28px; padding: 0 9px; border: 1px solid var(--border); border-radius: 5px; background: #fff; font-size: 10.5px; } +#screenerView .factor-data-status { min-height: 42px; display: flex; align-items: center; gap: 8px; padding: 0 16px; border-top: 1px solid var(--border); } +#screenerView .factor-data-status small { margin-left: auto; } + +#screenerView .screener-strategy-summary { min-height: 208px; display: flex; flex-direction: column; padding: 18px 18px 14px; } +#screenerView .screener-strategy-title { align-items: flex-start; } +#screenerView .screener-strategy-title strong { font-size: 18px; } +#screenerView .screener-strategy-summary > p { flex: 1; margin: 13px 0; color: var(--text-secondary); font-size: 12px; line-height: 1.75; } +#screenerView .screener-strategy-actions { margin-top: auto; } + +#screenerView .screener-stepper { min-height: 48px; margin: 0; padding: 6px 14px; box-shadow: none; } +#screenerView .screener-step .step-marker { width: 20px; height: 20px; } +#screenerView .screener-step strong { font-size: 10.5px; } +#screenerView .screener-step small { font-size: 9px; } +#screenerView .screener-runbar { min-height: 56px; padding: 9px 12px; } +#screenerView .screener-results-view, +#screenerView .strategy-tracking-panel { margin-top: 16px; padding: 13px; border: 1px solid var(--border); border-radius: var(--card-radius); background: var(--surface-raised); box-shadow: var(--card-shadow); } +#screenerView .screener-results-view .section-toolbar, +#screenerView .strategy-tracking-panel .section-toolbar { margin-bottom: 10px; } +#screenerView .screener-result-frame, +#screenerView .tracking-table-frame { border-radius: 7px; } + +.curated-screener-panel { gap: 16px; } +.curated-library-pane { padding: 16px; } +.curated-library-heading { min-height: 38px; } +.curated-library-controls { margin: 10px 0 14px; } +.curated-strategy-list { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; } +.curated-strategy-card { min-height: 190px; padding: 14px; } +.curated-card-foot { min-height: 38px; gap: 8px; } +.curated-card-actions { margin-left: auto; display: flex; align-items: center; gap: 6px; } +.curated-card-actions button { min-height: 28px; display: inline-flex; align-items: center; justify-content: center; gap: 4px; padding: 0 9px; border: 1px solid var(--border-strong); border-radius: 5px; background: #fff; color: var(--text-secondary); font-size: 10.5px; } +.curated-card-actions button.primary { border-color: var(--primary); background: var(--primary); color: #fff; } +.curated-card-actions button:disabled { border-color: var(--border); background: var(--xb-gray-100); color: var(--text-tertiary); cursor: not-allowed; } +.curated-card-actions .lucide { width: 11px; height: 11px; } +.curated-detail-dialog { width: min(860px, calc(100vw - 32px)); max-height: min(760px, calc(100dvh - 32px)); padding: 0; overflow: visible; border: 0; border-radius: 10px; background: transparent; } +.curated-detail-dialog::backdrop { background: rgba(20, 29, 44, .38); backdrop-filter: blur(3px); } +.curated-detail-dialog .curated-detail-pane { position: relative; max-height: min(760px, calc(100dvh - 32px)); overflow-y: auto; padding: 20px; } +.curated-detail-close { position: absolute; top: 14px; right: 14px; z-index: 2; } +.curated-detail-dialog .curated-detail-header { padding-right: 48px; } + +.quant-screener-panel { grid-template-columns: minmax(0, 1.12fr) minmax(380px, .88fr); gap: 14px; align-items: start; } +.quant-builder-pane, +.quant-summary-pane { min-width: 0; padding: 0; border: 1px solid var(--border); border-radius: var(--card-radius); background: #fff; box-shadow: var(--card-shadow); overflow: hidden; } +.quant-panel-heading, +.quant-summary-pane > header { min-height: 52px; display: flex; align-items: center; padding: 0 16px; border-bottom: 1px solid var(--border); background: var(--surface-subtle); } +.quant-panel-heading > div span, +.quant-summary-pane > header span { color: var(--primary); font-size: 10px; font-weight: 700; } +.quant-panel-heading h3, +.quant-summary-pane > header h3 { margin: 2px 0 0; font-size: 14px; } +.quant-panel-heading .button { margin-left: auto; } +.quant-universe-section, +.quant-rule-section { padding: 14px 16px; } +.quant-universe-section { border-bottom: 1px solid var(--border); } +.quant-universe-grid { grid-template-columns: repeat(3, minmax(100px, 1fr)); gap: 9px; margin: 10px 0 0; padding: 0; border: 0; background: transparent; } +.quant-st-toggle { grid-column: 1 / 4; min-height: 36px; padding: 0 10px; border-radius: 6px; background: var(--surface-subtle); } +.quant-rule-section + .quant-rule-section { border-top: 1px solid var(--border); } +.quant-rule-rows { gap: 8px; } +.quant-rule-row { min-height: 46px; border-color: var(--border); background: var(--surface-subtle); } +.quant-summary-pane > header { display: block; padding-top: 10px; } +.quant-filter-section { min-height: 164px; border-bottom: 1px solid var(--border); } +.quant-execution-heading { display: flex; align-items: center; padding: 14px 16px 0; } +.quant-execution-heading span { color: var(--text-primary); font-size: 12px; font-weight: 700; } +.quant-execution-heading small { margin-left: auto; color: var(--text-tertiary); font-size: 9.5px; } +.quant-formula-summary { margin: 10px 16px 14px; } +.quant-weight-status { margin: 0 16px 14px; } +.quant-summary-pane > .checkbox-control { margin: 0 16px 8px; } +.quant-summary-pane > .button { width: calc(100% - 32px); margin: 7px 16px 0; } +.quant-summary-pane > .quant-validation-message { margin: 10px 16px 14px; } + +/* Shared module spacing for every workspace. */ +.main-grid { gap: 14px; align-items: start; } +.main-grid > .table-frame { border-radius: var(--card-radius); } +.insight-rail { display: grid; gap: 12px; border: 0; background: transparent; } +.insight-rail .rail-section { border: 1px solid var(--border); border-radius: var(--card-radius); background: #fff; box-shadow: var(--card-shadow); overflow: hidden; } +.insight-rail .rail-section + .rail-section { border-top: 1px solid var(--border); } + +#performanceView { display: none; } +#performanceView.active-view { display: block; } +#performanceView .performance-cards { gap: 12px; margin-bottom: 12px; } +#performanceView .market-breadth-panel { margin: 0 0 12px; border: 1px solid var(--border); border-radius: var(--card-radius); background: #fff; box-shadow: var(--card-shadow); } +#performanceView .performance-table-frame { margin-top: 0; } + +.sentiment-cycle-summary { margin-bottom: 14px; border: 1px solid var(--border); border-radius: var(--card-radius); background: #fff; box-shadow: var(--card-shadow); overflow: hidden; } +.sentiment-cycle-analysis { gap: 14px; margin-bottom: 18px; } +.sentiment-trend-panel, +.sentiment-components-panel { border: 1px solid var(--border); border-radius: var(--card-radius); background: #fff; box-shadow: var(--card-shadow); overflow: hidden; } +.sentiment-detail-toolbar { margin-top: 0; } + +.auction-workspace-layout { gap: 14px; align-items: start; } +.auction-main-workspace { border: 1px solid var(--border); border-radius: var(--card-radius); background: #fff; box-shadow: var(--card-shadow); overflow: hidden; } +.auction-evidence-rail { display: grid; gap: 12px; border: 0; background: transparent; } +.auction-evidence-section, +.auction-news-entry { border: 1px solid var(--border); border-radius: var(--card-radius); background: #fff; box-shadow: var(--card-shadow); overflow: hidden; } +.auction-evidence-section + .auction-evidence-section, +.auction-news-entry { border-top: 1px solid var(--border); } +.auction-evidence-section .auction-theme-row { border-bottom-style: solid; border-bottom-color: #edf0f4; } + +#themeSummary, +#popularitySummary, +#dragonSummary { margin-bottom: 12px; border: 1px solid var(--border); border-radius: var(--card-radius); background: #fff; box-shadow: var(--card-shadow); overflow: hidden; } +.theme-library-layout { gap: 14px; border: 0; background: transparent; box-shadow: none; } +.theme-directory-panel, +.theme-detail-panel { border: 1px solid var(--border); border-radius: var(--card-radius); background: #fff; box-shadow: var(--card-shadow); overflow: hidden; } +.market-feature-filterbar { margin-bottom: 12px; border: 1px solid var(--border); border-radius: var(--card-radius); background: #fff; box-shadow: var(--card-shadow); } + +.dragon-filterbar { margin-bottom: 12px; border: 1px solid var(--border); border-radius: var(--card-radius); background: #fff; box-shadow: var(--card-shadow); } +.dragon-card-stage { margin-bottom: 14px; border: 1px solid var(--border); border-radius: var(--card-radius); background: #fff; box-shadow: var(--card-shadow); overflow: hidden; } +.dragon-trader-detail { margin-bottom: 14px; border: 1px solid var(--border); border-radius: var(--card-radius); background: #fff; box-shadow: var(--card-shadow); overflow: hidden; } + +.review-workspace { display: grid; grid-template-columns: minmax(0, .95fr) minmax(0, 1.05fr); gap: 14px; border: 0; background: transparent; box-shadow: none; } +.review-workspace .workspace-section { border: 1px solid var(--border); border-radius: var(--card-radius); background: #fff; box-shadow: var(--card-shadow); overflow: hidden; } +.review-workspace .watchlist-section, +.review-workspace .journal-section { min-height: 410px; } +.review-workspace .trade-journal-section, +.review-workspace .notes-history-section { grid-column: 1 / 3; } +.review-workspace .workspace-section + .workspace-section { border-top: 1px solid var(--border); } + +@media (max-width: 1280px) { + .overview-strip { grid-template-columns: minmax(160px, 1.15fr) repeat(5, minmax(64px, .5fr)) minmax(150px, 1fr) 82px; padding: 0 10px; } + .overview-strip .sentiment-block, + .overview-strip .metric { padding: 0 8px; } + .overview-toggle span { display: none; } + .ladder-workspace { grid-template-columns: minmax(0, 1fr) 260px; } + .ladder-step { grid-template-columns: 104px minmax(0, 1fr) auto; } + .ladder-step .ladder-stock { min-width: 180px; } + .rotation-history { grid-template-columns: repeat(9, minmax(126px, 1fr)); } + .curated-strategy-list { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .quant-screener-panel { grid-template-columns: minmax(0, 1fr) minmax(340px, .82fr); } +} + +@media (max-width: 900px) { + .app-main { margin-left: 0; } + .workspace-view, + body[data-active-view="screenerView"] .workspace-view, + body[data-active-view="mentorView"] .workspace-view, + body[data-active-view="reviewWorkspaceView"] .workspace-view { padding: 10px 10px 84px; } + .overview-strip { grid-template-columns: minmax(150px, 1.3fr) repeat(3, minmax(58px, .5fr)); padding: 0 8px; } + .overview-strip .metric:nth-of-type(n+4), + .overview-strip .metric-wide { display: none; } + .overview-toggle { justify-content: center; } + .overview-strip[data-overview-expanded="true"] { grid-template-columns: repeat(3, 1fr); padding: 8px; } + .overview-strip[data-overview-expanded="true"] .sentiment-block { grid-column: 1 / 4; min-height: 56px; } + .overview-strip[data-overview-expanded="true"] .metric:nth-of-type(n), + .overview-strip[data-overview-expanded="true"] .metric-wide { min-height: 54px; display: flex; border-bottom: 1px solid var(--border); } + .overview-strip[data-overview-expanded="true"] .overview-toggle { min-height: 54px; } + .section-toolbar { align-items: flex-start; flex-wrap: wrap; } + .section-title-group { align-items: flex-start; flex-direction: column; gap: 2px; } + .page-headline-stats { width: 100%; margin-left: 0; overflow-x: auto; } + .ladder-workspace { grid-template-columns: 1fr; } + .ladder-insights { grid-template-columns: 1fr; } + .ladder-step { grid-template-columns: 82px minmax(0, 1fr); } + .ladder-step-stocks { padding: 9px; } + .ladder-step .ladder-stock { min-width: min(100%, 188px); max-width: 100%; flex: 1 1 180px; } + .ladder-more { grid-column: 2; margin: 0 9px 9px; } + .rotation-history { grid-template-columns: repeat(9, 128px); } + .rotation-legend small { display: none; } + .screener-page-heading { margin-bottom: 0; pointer-events: auto; } + .screener-page-bar { align-items: stretch; flex-direction: column; gap: 0; } + .screener-mode-tabs { justify-content: stretch; } + .screener-mode-tabs button { flex: 1; padding: 0 5px; } + #screenerView .screener-overview-grid, + .quant-screener-panel { grid-template-columns: 1fr; } + #screenerView .screener-regime-body { grid-template-columns: 1fr 1fr; } + #screenerView .regime-evidence { grid-column: 1 / 3; min-height: 80px; } + .curated-library-controls { align-items: stretch; flex-direction: column; } + .curated-search { width: 100%; flex-basis: auto; } + .curated-strategy-list { grid-template-columns: 1fr; } + .curated-detail-grid { grid-template-columns: 1fr; gap: 14px; } + .review-workspace { grid-template-columns: 1fr; } + .review-workspace .trade-journal-section, + .review-workspace .notes-history-section { grid-column: 1; } +} + +@media (max-width: 720px) { + body, + body.sidebar-collapsed { + display: block; + min-height: 100dvh; + padding-bottom: calc(68px + env(safe-area-inset-bottom)); + } + + .app-header { + width: 100%; + height: 108px; + min-height: 108px; + position: relative; + display: flex; + align-items: flex-start; + padding: 8px 10px 0; + } + + .brand-block { height: 42px; } + .brand-mark { width: 34px; height: 34px; } + .brand-block h1 { font-size: 16px; } + .header-actions { position: absolute; inset: 56px 10px auto; display: flex; justify-content: space-between; gap: 6px; } + .header-date-group { height: 42px; min-width: 0; flex: 1; } + .header-date-group .date-input { width: 104px; flex: 1; } + .header-actions > .icon-button { width: 40px; min-width: 40px; min-height: 42px; } + + .module-nav, + body.sidebar-collapsed .module-nav { + width: 100%; + height: calc(64px + env(safe-area-inset-bottom)); + min-height: 64px; + position: fixed; + inset: auto 0 0; + z-index: 45; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + align-items: stretch; + padding: 4px 4px max(4px, env(safe-area-inset-bottom)); + overflow: hidden; + border-top: 1px solid var(--border); + border-right: 0; + background: rgba(255, 255, 255, .98); + box-shadow: 0 -5px 18px rgba(16, 24, 40, .08); + } + + .module-nav .nav-brand, + .module-nav .nav-group-label, + .module-nav .market-sub-tab, + .module-nav .sidebar-collapse-button { display: none; } + .module-nav .nav-group, + body.sidebar-collapsed .module-nav .nav-group { display: contents; margin: 0; padding: 0; border: 0; } + .module-nav .module-tab, + body.sidebar-collapsed .module-nav .module-tab { min-height: 54px; display: none; align-items: center; justify-content: center; flex-direction: column; gap: 3px; padding: 3px 2px; font-size: 10px; } + .module-nav .module-tab.mobile-primary-tab, + body.sidebar-collapsed .module-nav .module-tab.mobile-primary-tab { display: flex; } + + .app-main { width: 100%; min-height: calc(100dvh - 176px); margin: 0; padding: 10px 8px 20px; } + .mobile-market-selector:not([hidden]) { margin-bottom: 8px; } + .overview-strip { + min-height: 108px; + grid-template-columns: 1.3fr 1fr 1fr; + grid-template-rows: 54px 54px; + margin-bottom: 8px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + } + .overview-strip .sentiment-block { grid-column: 1; grid-row: 1 / 3; min-height: 108px; } + .overview-strip .metric:nth-of-type(1) { grid-column: 2; grid-row: 1; } + .overview-strip .metric:nth-of-type(2) { grid-column: 3; grid-row: 1; } + .overview-strip .metric:nth-of-type(3) { grid-column: 2; grid-row: 2; } + .overview-strip .metric:nth-of-type(4) { grid-column: 3; grid-row: 2; display: flex; } + .overview-strip .metric:nth-of-type(n+5), + .overview-strip .metric-wide { display: none; } + .overview-strip .sentiment-block, + .overview-strip .metric { min-height: 54px; } + .overview-toggle { display: none; } + .workspace-view, + body[data-active-view="screenerView"] .workspace-view, + body[data-active-view="mentorView"] .workspace-view, + body[data-active-view="reviewWorkspaceView"] .workspace-view { padding: 0 0 76px; } +} + +@media (prefers-reduced-motion: reduce) { + .workspace-view.active-view.view-entering, + .ladder-stock, + .curated-strategy-card { animation: none !important; transition: none !important; } +} + +/* Visual master alignment: based on the approved desktop prototypes. */ +:root { + --xb-blue-500: #2563eb; + --xb-blue-600: #1d4ed8; + --xb-red-500: #e04536; + --xb-green-500: #16a34a; + --xb-amber-500: #b45309; + --surface-canvas: #f4f5f7; + --surface-subtle: #f8fafc; + --text-primary: #1f2937; + --text-secondary: #6b7280; + --text-tertiary: #9ca3af; + --border: #e5e7eb; + --border-strong: #d1d5db; + --primary: var(--xb-blue-500); + --primary-hover: var(--xb-blue-600); + --danger: var(--xb-red-500); + --success: var(--xb-green-500); + --warning: var(--xb-amber-500); + --radius-sm: 5px; + --radius-md: 7px; + --radius-lg: 10px; + --shadow-xs: 0 1px 2px rgba(16, 24, 40, .05); + --shadow-sm: 0 4px 14px rgba(16, 24, 40, .06); + --card-radius: 10px; + --card-shadow: var(--shadow-xs); + --control-height: 32px; +} + +body { + grid-template-columns: 200px minmax(0, 1fr); + grid-template-rows: 46px minmax(0, 1fr) 30px; + background: var(--surface-canvas); + font-size: 13px; +} + +.app-header { + min-height: 46px; + height: 46px; + grid-template-columns: 200px minmax(260px, 1fr) auto; + padding: 0 14px 0 16px; +} + +.brand-block { min-width: 184px; gap: 8px; } +.brand-mark, +.brand-logo { width: 27px; height: 27px; } +.brand-block h1 { font-size: 15px; font-weight: 760; } + +.market-tape { display: flex; gap: 14px; font-size: 12px; } +.market-tape { grid-column: 2; grid-row: 1; min-width: 0; align-self: center; } +.market-item, +.market-item strong { font-size: 12px; white-space: nowrap; } + +.header-actions { grid-column: 3; grid-row: 1; align-self: center; gap: 4px; } +.header-date-group { min-height: 32px; padding: 1px 2px; border-radius: 7px; background: #fff; } +.header-date-group .date-input { width: 122px; height: 28px; padding: 0 3px; font-size: 12px; font-weight: 650; } +.header-date-group .icon-button { width: 25px; min-height: 28px; } +.header-date-group .lucide { width: 14px; height: 14px; } +.date-input::-webkit-calendar-picker-indicator { opacity: .48; } + +.header-actions > .icon-button { + width: 31px; + min-width: 31px; + min-height: 31px; + border-color: transparent; + background: transparent; + color: #647083; +} +.header-actions > .icon-button:hover { border-color: transparent; background: #f1f3f6; color: var(--primary); } +.header-actions > .icon-button .lucide { width: 15px; height: 15px; stroke-width: 1.8; } +.header-command-group { gap: 4px; } +.header-command-group .command-button { min-height: 31px; padding: 0 9px; border-radius: 7px; font-size: 11.5px; } +.header-command-group .command-button .lucide { width: 14px; height: 14px; } +.account-role-badges { gap: 3px; } +.account-role-badge { min-height: 27px; padding: 0 7px; border-radius: 14px; font-size: 10.5px; } +.account-button { max-width: 108px; } + +.module-nav { + top: 46px; + width: 200px; + height: calc(100vh - 46px); + padding: 8px 8px 10px; +} +.nav-brand { min-height: 28px; padding: 0 9px; font-size: 10px; } +.nav-group { gap: 1px; } +.nav-group + .nav-group { margin-top: 9px; padding-top: 8px; border-top: 1px solid var(--border); } +.nav-group-label { height: 23px; padding: 0 9px; font-size: 10.5px; } +.module-tab { min-height: 34px; gap: 8px; padding: 0 10px; border-radius: 7px; font-size: 12.5px; font-weight: 500; } +.module-tab .lucide { width: 15px; height: 15px; stroke-width: 1.75; color: #8893a4; } +.module-tab.active { position: relative; font-weight: 650; } +.module-tab.active::before { content: ""; width: 3px; position: absolute; inset: 8px auto 8px 0; border-radius: 0 3px 3px 0; background: var(--primary); } +.sidebar-collapse-button { min-height: 34px; border-top: 1px solid var(--border); border-radius: 0; } + +.app-main { padding: 0; } +.status-bar { padding: 0 16px; justify-content: center; color: var(--text-tertiary); font-size: 11px; } +.status-bar #statusText, +.status-bar #updatedAt { display: none; } +.status-bar .risk-note { margin: 0; } + +.overview-strip { + grid-template-columns: minmax(178px, 1.15fr) repeat(5, minmax(68px, .52fr)) minmax(168px, .9fr) 92px; + min-height: 32px; + height: 32px; + padding: 0 12px; +} +.overview-strip .sentiment-block, +.overview-strip .metric { min-height: 32px; flex-direction: row; align-items: center; gap: 6px; padding: 0 10px; } +.overview-strip .sentiment-gauge { width: 20px; height: 20px; flex-basis: 20px; font-size: 8.5px; } +.overview-strip .sentiment-text { font-size: 11.5px; } +.overview-strip .metric-label { font-size: 10px; } +.overview-strip .metric-value { font-size: 12px; } +.overview-toggle { min-height: 32px; font-size: 11px; } +.overview-strip[data-overview-expanded="true"] { min-height: 70px; } +.overview-strip[data-overview-expanded="true"] { height: 70px; } +.overview-strip[data-overview-expanded="true"] .sentiment-block, +.overview-strip[data-overview-expanded="true"] .metric { min-height: 70px; align-items: flex-start; flex-direction: column; justify-content: center; gap: 4px; } + +.workspace-view, +body[data-active-view="screenerView"] .workspace-view, +body[data-active-view="mentorView"] .workspace-view, +body[data-active-view="reviewWorkspaceView"] .workspace-view { padding: 14px 16px 24px; } +.section-toolbar { min-height: 34px; margin-bottom: 10px; } +.section-title-group h2 { font-size: 17px; font-weight: 800; } +.section-subtitle { font-size: 11.5px; } +.workspace-heading { min-height: 44px; padding: 9px 14px; } +.workspace-heading h3 { font-size: 14px; } +.table-frame, +.phase-table-frame { border-radius: var(--card-radius); } +.data-table thead th { height: 36px; font-size: 11.5px; } +.data-table tbody td { height: 41px; } + +/* Sentiment keeps the full history while adopting the prototype's visual focus. */ +.sentiment-cycle-analysis { grid-template-columns: minmax(0, 1fr) 340px; gap: 14px; align-items: start; margin-bottom: 16px; } +.sentiment-analysis-rail { display: grid; gap: 12px; } +.sentiment-trend-panel, +.sentiment-cycle-summary, +.sentiment-components-panel { margin: 0; border: 1px solid var(--border); border-radius: var(--card-radius); background: #fff; box-shadow: var(--card-shadow); overflow: hidden; } +.sentiment-trend-panel .workspace-heading > div span { display: block; margin-top: 2px; color: var(--text-tertiary); font-size: 10.5px; } +.sentiment-chart-legend { min-height: 34px; display: flex; align-items: center; gap: 18px; padding: 0 14px; border-bottom: 1px solid #edf0f4; color: var(--text-secondary); font-size: 10.5px; } +.sentiment-chart-legend span { display: inline-flex; align-items: center; gap: 6px; } +.sentiment-chart-legend span::before { content: ""; width: 12px; height: 3px; border-radius: 2px; background: var(--primary); } +.sentiment-chart-legend .retreat-point::before { width: 7px; height: 7px; border-radius: 50%; background: var(--danger); } +.sentiment-chart-legend .repair-point::before { width: 7px; height: 7px; border-radius: 50%; background: #e69a16; } +.sentiment-chart-legend .temperature-average::before { height: 1px; background: #c9d1dc; } +.sentiment-chart-shell { height: 320px; padding: 16px 18px 12px; } +.sentiment-cycle-summary { display: block; } +.sentiment-cycle-summary > .workspace-heading, +.sentiment-components-panel > .workspace-heading { border-bottom: 1px solid var(--border); } +.sentiment-cycle-current { min-height: 116px; display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 12px; align-items: center; padding: 13px 14px; border: 0; } +.sentiment-cycle-score-marker { width: 72px; height: 72px; display: flex; flex-direction: column; align-items: center; justify-content: center; border: 1px solid #f3c9c3; border-left-width: 1px; border-radius: 9px; background: var(--xb-red-50); } +.sentiment-cycle-score-marker strong { color: var(--danger); font-size: 25px; line-height: 1; } +.sentiment-cycle-score-marker span { margin-top: 6px; color: var(--text-secondary); font-size: 9.5px; } +.sentiment-current-copy h3 { margin: 3px 0 2px; font-size: 15px; } +.sentiment-current-copy small { color: var(--text-tertiary); font-size: 10.5px; } +.sentiment-stage-state { grid-column: 1 / 3; min-height: 54px; display: grid; grid-template-columns: 70px minmax(0, 1fr); align-items: center; gap: 8px; margin: 0 -14px -13px; padding: 9px 14px; border-top: 1px solid var(--border); background: var(--xb-amber-50); } +.sentiment-stage-state span { grid-row: 1 / 3; } +.sentiment-stage-state strong { font-size: 16px; } +.sentiment-stage-state small { color: #9a650e; font-size: 10.5px; } +.sentiment-cycle-foot { display: grid; grid-template-columns: 1fr 1fr; border-top: 1px solid var(--border); } +.sentiment-cycle-foot .sentiment-cycle-state { min-height: 72px; padding: 10px 13px; border-right: 1px solid var(--border); } +.sentiment-cycle-foot .sentiment-cycle-state:last-child { border-right: 0; } +.sentiment-cycle-state span, +.sentiment-cycle-state small { display: block; color: var(--text-tertiary); font-size: 10px; } +.sentiment-cycle-state strong { display: block; margin: 4px 0 2px; color: var(--text-primary); font-size: 14px; } +.sentiment-component-list { padding: 8px 14px 11px; } +.sentiment-component-row { padding: 8px 0; } +.sentiment-detail-toolbar { margin-top: 0; } +.sentiment-history-frame { max-height: none; } + +/* Ladder and pools use the same restrained hierarchy as the reference pages. */ +.main-grid { grid-template-columns: minmax(0, 1fr) 310px; gap: 12px; } +.insight-rail { gap: 12px; } +.rail-heading { min-height: 42px; } +.ladder-workspace { grid-template-columns: minmax(0, 1fr) 320px; } +.ladder-step { grid-template-columns: 118px minmax(0, 1fr) auto; } +.ladder-step .ladder-stock { min-width: 174px; max-width: 220px; min-height: 55px; } +.ladder-insights { gap: 12px; } +.ladder-insight-card > header { min-height: 42px; } + +/* Rotation order is a user choice; matrix remains a single nine-day canvas. */ +.rotation-order-control { margin-right: 2px; } +.rotation-history-heading { min-height: 43px; } +.rotation-history { grid-template-columns: repeat(9, minmax(118px, 1fr)); } +.rotation-day > header { min-height: 43px; } +.rotation-sector-chip { min-height: 39px; } + +/* Auction: remove the unfinished news panel and dedicate the rail to evidence. */ +.auction-workspace-layout { grid-template-columns: minmax(0, 1fr) 340px; gap: 12px; min-height: 590px; } +.auction-evidence-rail { display: grid; align-content: start; gap: 12px; background: transparent; } +.auction-evidence-section { border: 1px solid var(--border); border-radius: var(--card-radius); background: #fff; box-shadow: var(--card-shadow); } +.auction-theme-list { max-height: 390px; overflow-y: auto; scrollbar-width: thin; } +.auction-theme-row { min-height: 58px; } +.auction-news-entry { display: none !important; } + +/* Preserve the product's richer theme information architecture. */ +.theme-library-layout { grid-template-columns: 300px minmax(0, 1fr); gap: 14px; min-height: 650px; } +.theme-directory-panel, +.theme-detail-panel { border: 1px solid var(--border); border-radius: var(--card-radius); background: #fff; box-shadow: var(--card-shadow); } +.theme-chart-shell { height: 300px; } + +/* Screener follows the reference workbench proportions without hiding features. */ +.screener-page-bar { min-height: 46px; margin-bottom: 12px; } +.screener-page-bar .screener-page-heading, +.screener-page-bar .screener-mode-tabs { min-height: 45px; } +.screener-mode-tabs button { min-width: 108px; min-height: 45px; padding: 0 16px; } +#screenerView .screener-strategy-view { gap: 12px; } +#screenerView .screener-stepper { min-height: 48px; } +#screenerView .screener-overview-grid { gap: 12px; } +#screenerView .screener-runbar { min-height: 54px; } +#screenerView .screener-result-frame { min-height: 240px; } +#screenerView .tracking-table-frame { min-height: 160px; } +.curated-strategy-list { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; } +.curated-strategy-card { min-height: 184px; } +.quant-screener-panel { grid-template-columns: 400px minmax(0, 1fr); gap: 12px; } +.quant-builder-pane { order: 0; } +.quant-summary-pane { order: 1; } +.quant-rule-row { min-height: 44px; } + +/* Mentor: filters lead the page, conversation stays visually quiet. */ +.mentor-page-controls { display: flex; align-items: center; gap: 8px; margin-left: auto; } +.mentor-page-controls .mentor-evidence-filters { margin: 0; padding: 2px; border-radius: 7px; background: #eef0f3; } +.mentor-evidence-filters { display: inline-flex; gap: 2px; } +.mentor-evidence-filters button { min-height: 28px; padding: 0 12px; border-radius: 5px; color: var(--text-secondary); font-size: 11.5px; } +.mentor-evidence-filters button.active { background: #fff; color: var(--text-primary); box-shadow: 0 1px 2px rgba(16, 24, 40, .09); } +.mentor-evidence-filters button[data-mentor-grade="A"] { color: #16814a; } +.mentor-evidence-filters button[data-mentor-grade="B"] { color: #2e67c7; } +.mentor-evidence-filters button[data-mentor-grade="C"] { color: #a76608; } +.mentor-layout { height: calc(100vh - 142px); min-height: 620px; grid-template-columns: 340px minmax(0, 1fr); border: 1px solid var(--border); border-radius: var(--card-radius); background: #fff; box-shadow: var(--card-shadow); overflow: hidden; } +.mentor-chat-header { min-height: 82px; padding: 12px 16px; } +.mentor-messages { padding: 18px; } +.mentor-chat-form { min-height: 58px; padding: 8px 14px; } +.mentor-chat-form textarea { height: 42px; min-height: 42px; max-height: 92px; padding: 9px 11px; resize: vertical; } +.mentor-chat-form .button { min-height: 38px; } + +/* Review mirrors the approved daily workflow instead of equal-width form columns. */ +.review-workspace { grid-template-columns: minmax(0, 1fr) 360px; grid-template-areas: "watch journal" "trades journal" "notes notes"; gap: 12px; background: transparent; } +.review-workspace .watchlist-section { grid-area: watch; min-height: 0; } +.review-workspace .journal-section { grid-area: journal; min-height: 0; } +.review-workspace .trade-journal-section { grid-area: trades; min-height: 0; } +.review-workspace .notes-history-section { grid-area: notes; } +.review-workspace .workspace-section { border: 1px solid var(--border); border-radius: var(--card-radius); box-shadow: var(--card-shadow); } +.review-workspace .workspace-section + .workspace-section { border-top: 1px solid var(--border); } +.journal-form textarea { min-height: 98px; } + +@media (min-width: 1280px) and (max-width: 1510px) { + .market-tape { gap: 10px; } + .header-command-group .command-button { width: 31px; padding: 0; } + .header-command-group .command-button > span { display: none; } + .account-role-badge span { display: none; } + .account-role-badge { min-width: 27px; justify-content: center; } +} + +@media (min-width: 1380px) and (max-width: 1510px) { + #settingsButton, + .header-command-group .account-button { width: auto; padding: 0 9px; } + #settingsButton > span, + .header-command-group .account-button > span { display: inline; } +} + +@media (min-width: 721px) and (max-width: 1279px) { + body { grid-template-columns: 64px minmax(0, 1fr); } + .app-header { grid-template-columns: 176px minmax(0, 1fr) auto; } + .market-tape { display: none; } + .module-nav { width: 64px; } + .quant-screener-panel { grid-template-columns: 1fr; } + .curated-strategy-list { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} + +@media (max-width: 900px) { + .sentiment-cycle-analysis, + .ladder-workspace, + .auction-workspace-layout, + .review-workspace { grid-template-columns: 1fr; } + .review-workspace { grid-template-areas: "watch" "journal" "trades" "notes"; } + .sentiment-analysis-rail { grid-template-columns: 1fr; } + .mentor-page-controls { width: 100%; justify-content: space-between; } + .mentor-page-controls .mentor-evidence-filters { overflow-x: auto; } +} + +/* Keep the final desktop precision pass from overriding the mobile shell. */ +@media (max-width: 720px) { + body, + body.sidebar-collapsed { + display: block; + min-height: 100dvh; + padding-bottom: calc(68px + env(safe-area-inset-bottom)); + } + + .app-header { + width: 100%; + height: 108px; + min-height: 108px; + position: relative; + display: flex; + align-items: flex-start; + padding: 8px 10px 0; + } + + .brand-block { height: 42px; } + .brand-mark, + .brand-logo { width: 34px; height: 34px; } + .brand-block h1 { font-size: 16px; } + .header-actions { position: absolute; inset: 56px 10px auto; display: flex; justify-content: space-between; gap: 6px; } + .header-date-group { height: 42px; min-width: 0; flex: 1; } + .header-date-group .date-input { width: 104px; flex: 1; } + .header-actions > .icon-button { width: 40px; min-width: 40px; min-height: 42px; } + + .module-nav, + body.sidebar-collapsed .module-nav { + width: 100%; + height: calc(64px + env(safe-area-inset-bottom)); + min-height: 64px; + position: fixed; + inset: auto 0 0; + z-index: 45; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + align-items: stretch; + padding: 4px 4px max(4px, env(safe-area-inset-bottom)); + overflow: hidden; + border-top: 1px solid var(--border); + border-right: 0; + background: rgba(255, 255, 255, .98); + box-shadow: 0 -5px 18px rgba(16, 24, 40, .08); + } + + .module-nav .module-tab, + body.sidebar-collapsed .module-nav .module-tab { min-height: 54px; } + + .app-main { width: 100%; min-height: calc(100dvh - 176px); margin: 0; padding: 10px 8px 20px; } + .workspace-view, + body[data-active-view="screenerView"] .workspace-view, + body[data-active-view="mentorView"] .workspace-view, + body[data-active-view="reviewWorkspaceView"] .workspace-view { padding: 0 0 76px; } + + .mentor-layout { height: auto; min-height: calc(100dvh - 252px); grid-template-columns: 1fr; } + .quant-screener-panel, + #screenerView .screener-overview-grid, + .review-workspace { grid-template-columns: 1fr; } + .curated-strategy-list { grid-template-columns: 1fr; } +} + +/* Strict prototype alignment: visible hierarchy and proportions. */ +.brand-block .brand-mark, +.auth-brand .brand-mark { + width: 27px; + height: 27px; + flex: 0 0 27px; + display: grid; + place-items: center; + border: 0; + border-radius: 7px; + background: #2563eb; + color: #fff; + box-shadow: none; +} +.auth-brand .brand-mark { width: 42px; height: 42px; flex-basis: 42px; } +.brand-glyph { + font-family: "Songti SC", "SimSun", serif; + font-size: 14px; + font-weight: 800; + line-height: 1; +} + +.workspace-view:not(#heavenView) .section-toolbar:first-child { + min-height: 32px; + margin-bottom: 10px; +} +.workspace-view:not(#heavenView) .section-title-group h2 { font-size: 17px; } +.workspace-view:not(#heavenView) .section-subtitle { color: #98a1b2; font-size: 11px; } + +body[data-active-view="screenerView"] .overview-strip, +body[data-active-view="mentorView"] .overview-strip, +body[data-active-view="reviewWorkspaceView"] .overview-strip { display: grid; } + +/* Sentiment follows the approved chart + compact analysis + guide composition. */ +.sentiment-cycle-analysis { + grid-template-columns: minmax(0, 1fr) 340px; + gap: 12px; + margin-bottom: 12px; +} +.sentiment-trend-panel { min-height: 398px; } +.sentiment-chart-shell { height: 320px; padding: 14px 18px 10px; } +.sentiment-analysis-rail { gap: 12px; } +.sentiment-cycle-current { min-height: 102px; padding: 11px 14px; } +.sentiment-cycle-foot .sentiment-cycle-state { min-height: 48px; padding: 7px 12px; } +.sentiment-component-list { padding: 4px 14px 7px; } +.sentiment-component-item { min-height: 0; padding: 4px 0; } +.sentiment-component-item > div:first-child { grid-template-columns: minmax(76px, 1fr) auto 34px; } +.sentiment-component-item small { display: block; overflow: hidden; margin-top: 1px; font-size: 9px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; } +.sentiment-component-track { margin-top: 4px; } + +.sentiment-stage-guide { + margin-bottom: 12px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 10px; + background: #fff; + box-shadow: var(--shadow-xs); +} +.sentiment-stage-guide .workspace-heading { min-height: 42px; border-bottom: 1px solid var(--border); } +.sentiment-stage-guide-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); } +.sentiment-stage-guide-grid article { + min-width: 0; + min-height: 82px; + display: grid; + align-content: center; + gap: 4px; + padding: 10px 12px; + border-right: 1px solid var(--border); + background: #fff; +} +.sentiment-stage-guide-grid article:last-child { border-right: 0; } +.sentiment-stage-guide-grid strong { font-size: 12.5px; } +.sentiment-stage-guide-grid span, +.sentiment-stage-guide-grid small { overflow: hidden; color: var(--text-secondary); font-size: 10px; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; } +.sentiment-stage-guide-grid small { color: var(--text-tertiary); } +.sentiment-stage-guide-grid article.current { + background: #fff0ee; + box-shadow: inset 0 -2px 0 var(--danger); +} +.sentiment-stage-guide-grid article.current strong, +.sentiment-stage-guide-grid article.current small { color: var(--danger); } +.sentiment-detail-toolbar { margin-top: 0; } + +/* Pool pages use the prototype's compact table-first proportions. */ +.main-grid { grid-template-columns: minmax(0, 1fr) 320px; gap: 12px; } +.main-grid .data-table thead th { height: 34px; padding: 6px 10px; } +.main-grid .data-table tbody td { height: 40px; padding: 5px 10px; } +.insight-rail { gap: 12px; } +.insight-card { border-radius: 10px; box-shadow: var(--shadow-xs); } + +/* Screener uses the prototype's compact workbench instead of tall empty canvases. */ +#screenerView .screener-strategy-view { gap: 12px; } +#screenerView .screener-stepper { min-height: 56px; padding: 8px 14px; } +#screenerView .screener-overview-card { padding: 12px 14px; } +#screenerView .screener-regime-body { min-height: 94px; } +#screenerView .screener-strategy-card { min-height: 182px; } +#screenerView .screener-result-frame { min-height: 220px; } +#screenerView .tracking-table-frame { min-height: 130px; } +#screenerView .screener-results-view, +#screenerView .strategy-tracking-panel { padding: 12px 14px; border: 1px solid var(--border); border-radius: 10px; background: #fff; box-shadow: var(--shadow-xs); } +#screenerView .screener-results-view .result-toolbar, +#screenerView .strategy-tracking-panel .result-toolbar { min-height: 42px; } + +/* Review mirrors the reference's compact two-column daily workflow. */ +.review-workspace { + grid-template-columns: minmax(0, 1fr) 360px; + grid-template-areas: "watch journal" "trades journal" "notes notes"; + align-items: start; + gap: 12px; +} +.review-workspace .workspace-section { min-height: 0 !important; } +.review-workspace .workspace-heading { min-height: 42px; padding: 8px 14px; } +.review-workspace .watchlist-section { grid-area: watch; } +.review-workspace .journal-section { grid-area: journal; } +.review-workspace .trade-journal-section { grid-area: trades; } +.review-workspace .notes-history-section { grid-area: notes; } +.watchlist-section .workspace-table-frame { min-height: 0; } +.watchlist-section .data-table tbody td { height: 41px; } +.journal-form { padding: 13px 16px 14px; } +.journal-form .form-field { gap: 5px; } +.journal-form textarea { min-height: 82px; height: 82px; } +.journal-form .dialog-actions { margin-top: 4px; } +.trade-journal-section .trade-log-summary { min-height: 58px; } +.trade-log-table-frame { min-height: 96px; } +.trade-log-table-frame .empty-state { min-height: 94px; } +.notes-history-section .notes-history { min-height: 84px; } +.notes-history .note-item { padding: 12px 14px; } + +/* Mentor proportions from the prototype: compact directory and quieter composer. */ +.mentor-layout { grid-template-columns: 300px minmax(0, 1fr); } +.mentor-chat-header { min-height: 76px; } +.mentor-chat-form { min-height: 56px; padding: 7px 12px; } +.mentor-chat-form textarea { height: 40px; min-height: 40px; } +.mentor-chat-form .button { min-height: 38px; } + +@media (max-width: 900px) { + .sentiment-stage-guide-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .sentiment-stage-guide-grid article:nth-child(3) { border-right: 0; } + .sentiment-stage-guide-grid article:nth-child(-n+3) { border-bottom: 1px solid var(--border); } +} + +@media (max-width: 720px) { + .sentiment-stage-guide-grid { grid-template-columns: 1fr 1fr; } + .sentiment-stage-guide-grid article, + .sentiment-stage-guide-grid article:nth-child(3) { border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); } + .sentiment-stage-guide-grid article:nth-child(even) { border-right: 0; } + .sentiment-stage-guide-grid article:nth-last-child(-n+2) { border-bottom: 0; } + .review-workspace { grid-template-columns: 1fr; grid-template-areas: "watch" "journal" "trades" "notes"; } + .mentor-layout { grid-template-columns: 1fr; } +} + +/* 2026-07-25 structural alignment pass + These rules mirror the approved prototypes' composition, not the legacy page geometry. */ +@media (min-width: 901px) { + .workspace-view:not(#heavenView) { padding: 14px 16px 22px; } + .workspace-view:not(#heavenView) > .section-toolbar:first-child, + .screener-page-bar { min-height: 34px; margin: 0 0 10px; } +} + +.workspace-view:not(#heavenView) .section-title-group { gap: 9px; } +.workspace-view:not(#heavenView) .section-title-group h2 { font-size: 17px; font-weight: 800; } +.workspace-view:not(#heavenView) .section-subtitle { font-size: 11.5px; } +.workspace-heading, +.screener-card-heading, +.quant-panel-heading { min-height: 42px; padding: 9px 14px; } +.workspace-heading h3, +.screener-card-heading h3, +.quant-panel-heading h3 { font-size: 14px; font-weight: 750; } + +/* The judgement guide is a reference table, matching the approved emotion page. */ +.sentiment-stage-guide { border-radius: 10px; } +.sentiment-stage-guide-head, +.sentiment-stage-guide-grid article { + display: grid; + grid-template-columns: 17% minmax(260px, 1fr) 17% 25%; + align-items: center; +} +.sentiment-stage-guide-head { + min-height: 34px; + padding: 0 14px; + border-bottom: 1px solid var(--border); + background: #f8fafc; + color: var(--text-secondary); + font-size: 11.5px; + font-weight: 650; +} +.sentiment-stage-guide-grid { display: block; } +.sentiment-stage-guide-grid article, +.sentiment-stage-guide-grid article:last-child { + min-height: 35px; + padding: 0 14px; + gap: 0; + border: 0; + border-bottom: 1px solid var(--xb-gray-100); + background: #fff; + box-shadow: none; +} +.sentiment-stage-guide-grid article:last-child { border-bottom: 0; } +.sentiment-stage-guide-grid article > * { min-width: 0; padding-right: 14px; } +.sentiment-stage-guide-grid article strong { font-size: 12.5px; } +.sentiment-stage-guide-grid article span, +.sentiment-stage-guide-grid article small { + display: block; + margin: 0; + overflow: visible; + color: var(--text-primary); + font-size: 11.5px; + line-height: 1.35; + text-overflow: clip; + white-space: normal; +} +.sentiment-stage-guide-grid article .stage-range { color: var(--text-secondary); font-variant-numeric: tabular-nums; } +.sentiment-stage-guide-grid article.current { + background: var(--xb-red-50); + box-shadow: inset 3px 0 0 var(--danger); +} +.sentiment-stage-guide-grid article.current strong, +.sentiment-stage-guide-grid article.current small { color: var(--danger); } +.sentiment-cycle-analysis { align-items: stretch; } +.sentiment-trend-panel { min-height: 0; } +.sentiment-chart-shell { height: 296px; } +.sentiment-cycle-summary, +.sentiment-components-panel { min-height: 0; } +.sentiment-history-frame { min-height: 0; max-height: none; } +.sentiment-history-table tbody td { height: 35px; padding-top: 5px; padding-bottom: 5px; } + +/* Pool pages share the prototype's table-first density. */ +#limitPool .data-table, +#brokenView .data-table, +#downView .data-table, +#yesterdayView .data-table, +#performanceView .data-table, +#popularityView .data-table { font-size: 12px; } +#limitPool .data-table thead th, +#brokenView .data-table thead th, +#downView .data-table thead th, +#yesterdayView .data-table thead th, +#performanceView .data-table thead th, +#popularityView .data-table thead th { height: 32px; padding: 6px 9px; font-size: 11.5px; } +#limitPool .data-table tbody td, +#brokenView .data-table tbody td, +#downView .data-table tbody td, +#yesterdayView .data-table tbody td, +#performanceView .data-table tbody td, +#popularityView .data-table tbody td { height: 39px; padding: 5px 9px; } +#limitPool .main-grid { grid-template-columns: minmax(0, 1fr) 308px; } +#limitPool .table-frame, +.phase-table-frame, +.performance-table-frame, +.market-feature-table-frame { border-radius: 10px; background: #fff; box-shadow: var(--shadow-xs); } +.phase-table-frame, +.performance-table-frame, +.market-feature-table-frame { min-height: 300px; } +.toolbar-controls .search-field { height: 34px; } + +/* Screener: one compact workbench, then results. */ +.screener-page-bar { align-items: center; border-bottom: 0; } +.screener-mode-tabs { align-self: stretch; padding: 3px; border: 1px solid var(--border); border-radius: 10px; background: #fff; } +.screener-mode-tabs button { min-width: 116px; min-height: 30px; padding: 0 15px; border: 0; border-radius: 7px; } +.screener-mode-tabs button.active { background: var(--primary); color: #fff; box-shadow: none; } +.screener-mode-tabs button.active::after { display: none; } +.screener-stepper { padding: 10px 18px !important; } +.screener-step { min-width: 150px; } +.screener-step .step-marker { width: 24px; height: 24px; border-radius: 50%; } +.screener-step[data-state="complete"] .step-marker { background: var(--success); color: transparent; } +.screener-step[data-state="complete"] .step-marker::after { content: "✓"; color: #fff; font-weight: 800; } +.screener-step[data-state="active"] .step-marker { background: var(--primary); color: #fff; } +.screener-overview-grid { grid-template-columns: 1fr 1fr; gap: 12px; } +.screener-overview-card { min-height: 0; border-radius: 10px; box-shadow: var(--shadow-xs); } +#screenerView .screener-regime-body { min-height: 0; display: grid; grid-template-columns: 112px 128px minmax(0, 1fr); gap: 0; padding: 0; } +#screenerView .regime-summary { grid-row: auto; align-self: stretch; display: grid; place-content: center; min-height: 100px; padding: 10px; border: 0; border-right: 1px solid var(--border); border-radius: 0; background: var(--xb-red-50); text-align: center; } +#screenerView .regime-summary strong { color: var(--danger); font-size: 19px; } +#screenerView .regime-temperature { align-self: stretch; min-height: 100px; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; gap: 4px; padding: 12px 14px; border-right: 1px solid var(--border); } +#screenerView .regime-temperature strong { font-size: 15px; } +#screenerView .regime-evidence { align-self: stretch; min-height: 100px; max-height: 100px; padding: 12px 14px; overflow: hidden; } +#screenerView .regime-selector { min-height: 38px; margin: 0; padding: 5px 16px; border-top: 1px solid var(--border); background: #fff; } +#screenerView .regime-selector button { min-height: 26px; } +#screenerView .factor-data-status { min-height: 28px; padding: 5px 16px; border-top: 1px solid var(--border); background: #f8fafc; } +#screenerView .screener-strategy-summary { min-height: 166px; padding: 14px 16px; } +#screenerView .screener-strategy-title strong { font-size: 15px; } +#screenerView .screener-strategy-summary p { min-height: 38px; margin: 8px 0 10px; line-height: 1.65; } +.screener-runbar { min-height: 52px; padding: 8px 14px; border-radius: 10px; } +.screener-pipeline-status { margin-left: auto; } +#screenerView .screener-results-view, +#screenerView .strategy-tracking-panel { padding: 0; overflow: hidden; } +#screenerView .screener-result-frame { min-height: 230px; border-width: 1px 0 0; border-radius: 0; } +#screenerView .tracking-table-frame { min-height: 120px; border-width: 1px 0 0; border-radius: 0; } +.curated-screener-panel { display: block; } +.curated-library-pane { border: 0; background: transparent; box-shadow: none; } +.curated-library-heading, +.curated-library-controls { padding-left: 0; padding-right: 0; } +.curated-strategy-list { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; padding: 0; } +.curated-strategy-card { min-height: 154px; border: 1px solid var(--border); border-radius: 10px; background: #fff; box-shadow: var(--shadow-xs); } +.curated-strategy-card:hover { border-color: var(--xb-blue-100); box-shadow: 0 4px 14px rgba(37, 99, 235, .08); } +.quant-screener-panel { grid-template-columns: 400px minmax(0, 1fr); gap: 12px; align-items: start; } +.quant-builder-pane, +.quant-summary-pane { border-radius: 10px; box-shadow: var(--shadow-xs); } +.quant-rule-row { min-height: 42px; padding: 7px 12px; } +.quant-weight-control { + height: 38px; + display: grid; + grid-template-columns: minmax(72px, 1fr) 42px; + align-items: center; + gap: 8px; + padding: 0 8px; + border: 1px solid var(--border-strong); + border-radius: 6px; + background: #fff; +} +.quant-rule-row .quant-weight-control input[type="range"] { + width: 100%; + height: 4px; + padding: 0; + border: 0; + border-radius: 999px; + background: #dfe5ee; + accent-color: var(--primary); + box-shadow: none; + cursor: pointer; +} +.quant-weight-control output { + color: var(--primary); + font-size: 11.5px; + font-weight: 750; + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* Auction: dominant candidate table, compact evidence rail. */ +.auction-workspace-layout { grid-template-columns: minmax(0, 1fr) 330px; gap: 12px; } +.auction-main-workspace, +.auction-evidence-section { border-radius: 10px; box-shadow: var(--shadow-xs); } +.auction-dataset-bar { min-height: 46px; padding: 6px 12px; } +.auction-dataset-segments { padding: 0; background: transparent; } +.auction-dataset-segments .segment { min-height: 34px; border-radius: 7px; } +.auction-expectation-filterbar { min-height: 44px; padding: 6px 12px; } +.auction-unified-table-frame { max-height: calc(100vh - 270px); min-height: 430px; border: 0; border-top: 1px solid var(--border); border-radius: 0; } +.auction-evidence-rail { gap: 12px; } +.auction-evidence-section { padding: 0; overflow: hidden; } +.auction-insight-heading { padding: 11px 14px; border-bottom: 1px solid var(--border); } +.auction-theme-list, +.auction-new-theme-line, +.auction-amount-trend, +.auction-amount-compare { margin-left: 14px; margin-right: 14px; } + +/* Popularity remains intentionally simple. */ +#popularityView .market-feature-summary { margin-bottom: 12px; } +#popularityView .market-feature-filterbar { min-height: 46px; padding: 6px 10px; border: 1px solid var(--border); border-bottom: 0; border-radius: 10px 10px 0 0; background: #fff; } +#popularityView .market-feature-table-frame { border-radius: 0 0 10px 10px; } + +/* Mentor: compact directory and an input that does not dominate the conversation. */ +.mentor-layout { grid-template-columns: 292px minmax(0, 1fr); gap: 12px; } +.mentor-sidebar, +.mentor-chat-panel { border-radius: 10px; box-shadow: var(--shadow-xs); } +.mentor-directory-heading { min-height: 42px; } +.mentor-search-field { margin: 10px 12px 7px; } +.mentor-list { padding: 0 8px 8px; } +.mentor-option { border-radius: 7px; } +.mentor-chat-header { min-height: 68px; padding: 10px 14px; } +.mentor-chat-form { min-height: 54px; padding: 7px 10px; } +.mentor-chat-form textarea { height: 38px; min-height: 38px; } + +/* Review: prototype's left workflow + right daily form. History opens on demand. */ +#reviewWorkspaceView > .section-toolbar { margin-bottom: 10px; } +.review-workspace { + grid-template-columns: minmax(0, 1fr) 360px; + grid-template-areas: "watch journal" "trades journal" "notes notes"; + align-items: start; + gap: 12px; +} +.review-workspace .workspace-section { overflow: hidden; border-radius: 10px; box-shadow: var(--shadow-xs); } +.review-workspace .watchlist-section { grid-area: watch; } +.review-workspace .trade-journal-section { grid-area: trades; } +.review-workspace .journal-section { grid-area: journal; } +.review-workspace .notes-history-section { grid-area: notes; } +.review-workspace .workspace-heading { min-height: 42px; } +.watchlist-section .data-table thead th { height: 32px; padding: 6px 12px; } +.watchlist-section .data-table tbody td { height: 44px; padding: 7px 12px; } +.watchlist-section .workspace-table-frame { max-height: 240px; } +.watchlist-section .empty-state { min-height: 150px; } +.trade-journal-section .trade-log-summary:empty { display: none; } +.trade-journal-section .trade-log-summary:not(:empty) { min-height: 56px; } +.trade-log-table-frame { min-height: 102px; } +.trade-log-table-frame .empty-state { min-height: 100px; } +.journal-form { padding: 14px 16px; } +.journal-form .form-field > span { color: var(--text-primary); font-size: 12px; font-weight: 700; } +.journal-form textarea { min-height: 112px; height: 112px; line-height: 1.7; } +.journal-form .form-field:last-of-type textarea { min-height: 86px; height: 86px; } +.journal-form .dialog-actions { justify-content: flex-end; } +.notes-history-section[hidden] { display: none !important; } +#reviewHistoryToggle[aria-expanded="true"] { border-color: var(--primary); color: var(--primary); } + +@media (max-width: 1100px) { + .sentiment-stage-guide-head, + .sentiment-stage-guide-grid article { grid-template-columns: 110px minmax(230px, 1fr) 120px minmax(190px, .8fr); } + .auction-workspace-layout { grid-template-columns: 1fr; } + .auction-evidence-rail { grid-template-columns: 1fr 1fr; } + .quant-screener-panel { grid-template-columns: 360px minmax(0, 1fr); } +} + +@media (max-width: 720px) { + .sentiment-stage-guide-head { display: none; } + .sentiment-stage-guide-grid { display: grid; grid-template-columns: 1fr; } + .sentiment-stage-guide-grid article, + .sentiment-stage-guide-grid article:nth-child(3), + .sentiment-stage-guide-grid article:nth-child(even), + .sentiment-stage-guide-grid article:nth-last-child(-n+2) { + grid-template-columns: 72px minmax(0, 1fr) 92px; + min-height: 60px; + padding: 8px 10px; + border-right: 0; + border-bottom: 1px solid var(--border); + } + .sentiment-stage-guide-grid article small { grid-column: 2 / -1; margin-top: 3px; } + .screener-mode-tabs { width: 100%; overflow-x: auto; } + .screener-mode-tabs button { min-width: 104px; } + .screener-overview-grid, + .quant-screener-panel, + .auction-evidence-rail, + .review-workspace { grid-template-columns: 1fr; } + .review-workspace { grid-template-areas: "watch" "journal" "trades" "notes"; } +} diff --git a/static/styles.css b/static/styles.css index 5e66951..780b300 100644 --- a/static/styles.css +++ b/static/styles.css @@ -42,6 +42,200 @@ font-size: 14px; } +/* Strategy library and quantitative screener */ +.screener-mode-tabs { + min-height: 48px; + display: flex; + align-items: flex-end; + gap: 4px; + padding: 0 14px; + border-bottom: 1px solid #e5e7eb; + background: #f4f5f7; +} + +.screener-mode-tabs button { + min-width: 120px; + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 16px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: #64748b; + font: inherit; + font-size: 13px; + font-weight: 650; + cursor: pointer; + transition: color 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.screener-mode-tabs button:hover { background: rgba(255, 255, 255, .58); color: #1f2937; } +.screener-mode-tabs button.active { border-bottom-color: #2563eb; color: #1d4ed8; } +.screener-mode-tabs .lucide { width: 16px; height: 16px; } + +.curated-screener-panel, +.quant-screener-panel { + min-height: 620px; + display: grid; + margin: 0 14px 14px; + overflow: hidden; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + box-shadow: 0 1px 2px rgba(15, 23, 42, .05); +} + +.curated-screener-panel { grid-template-columns: 330px minmax(0, 1fr); } +.curated-library-pane { min-width: 0; padding: 16px; border-right: 1px solid #e5e7eb; background: #f8fafc; } +.curated-library-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.curated-library-heading span, +.quant-panel-heading span, +.quant-summary-pane > header span, +.curated-detail-header > div > span { color: #64748b; font-size: 12px; font-weight: 650; } +.curated-library-heading h3, +.quant-panel-heading h3, +.quant-summary-pane > header h3, +.curated-detail-header h3 { margin: 3px 0 0; color: #111827; font-size: 17px; letter-spacing: 0; } +.curated-library-heading > strong { padding: 4px 8px; border-radius: 5px; background: #e8eefc; color: #1d4ed8; font-size: 12px; } + +.curated-search { height: 42px; display: flex; align-items: center; gap: 8px; margin-top: 14px; padding: 0 11px; border: 1px solid #d8dde5; border-radius: 6px; background: #fff; } +.curated-search:focus-within { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, .12); } +.curated-search .lucide { width: 16px; height: 16px; color: #94a3b8; } +.curated-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: #111827; font-size: 13px; } +.curated-category-filters { display: flex; gap: 6px; margin-top: 10px; overflow-x: auto; scrollbar-width: none; } +.curated-category-filters::-webkit-scrollbar { display: none; } +.curated-category-filters button { min-height: 34px; flex: 0 0 auto; padding: 0 10px; border: 1px solid #e1e5eb; border-radius: 5px; background: #fff; color: #64748b; font-size: 12px; cursor: pointer; } +.curated-category-filters button.active { border-color: #b8c8f2; background: #edf3ff; color: #1d4ed8; font-weight: 700; } +.curated-strategy-list { display: grid; gap: 7px; margin-top: 12px; } +.curated-strategy-card { width: 100%; min-height: 76px; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 9px; padding: 9px 10px; border: 1px solid #e5e7eb; border-radius: 6px; background: #fff; color: #1f2937; text-align: left; cursor: pointer; transition: border-color 180ms ease, box-shadow 180ms ease, background 180ms ease; } +.curated-strategy-card:hover { border-color: #c9d5ef; box-shadow: 0 2px 8px rgba(37, 99, 235, .08); } +.curated-strategy-card.active { border-color: #8da9e8; background: #f3f7ff; box-shadow: inset 3px 0 #2563eb; } +.curated-strategy-rank { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 5px; background: #eef2f7; color: #475569; font-variant-numeric: tabular-nums; font-size: 12px; font-weight: 800; } +.curated-strategy-card.active .curated-strategy-rank { background: #dce8ff; color: #1d4ed8; } +.curated-strategy-copy { min-width: 0; } +.curated-strategy-copy strong, +.curated-strategy-copy small { display: block; } +.curated-strategy-copy strong { overflow: hidden; color: #111827; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } +.curated-strategy-copy small { margin-top: 5px; color: #7c8798; font-size: 12px; } +.curated-ready-dot { width: 9px; height: 9px; border-radius: 50%; background: #16a34a; box-shadow: 0 0 0 3px rgba(22, 163, 74, .1); } +.curated-ready-dot.missing { background: #dc8b18; box-shadow: 0 0 0 3px rgba(220, 139, 24, .12); } + +.curated-detail-pane { min-width: 0; display: flex; flex-direction: column; padding: 20px 22px; } +.curated-detail-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; padding-bottom: 18px; border-bottom: 1px solid #e8ebef; } +.curated-detail-header p { max-width: 680px; margin: 9px 0 0; color: #64748b; font-size: 13px; line-height: 1.65; } +.curated-strategy-badges { display: flex; justify-content: flex-end; gap: 6px; flex-wrap: wrap; } +.curated-strategy-badges span { min-height: 28px; display: inline-flex; align-items: center; padding: 0 9px; border-radius: 5px; background: #f1f5f9; color: #475569; font-size: 12px; font-weight: 650; white-space: nowrap; } +.curated-strategy-badges span:first-child { background: #eaf1ff; color: #1d4ed8; } +.curated-detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, .9fr); gap: 26px; padding: 20px 0; } +.curated-condition-section { min-width: 0; } +.mini-section-heading { min-height: 36px; display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.mini-section-heading h4 { margin: 0; color: #1f2937; font-size: 14px; } +.mini-section-heading p { margin: 4px 0 0; color: #94a3b8; font-size: 12px; } +.mini-section-heading > span { color: #64748b; font-size: 12px; } +.curated-rule-list, +.curated-score-list { display: grid; gap: 6px; margin-top: 7px; } +.curated-rule-row { min-height: 43px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 7px 10px; border-bottom: 1px solid #eef0f3; } +.curated-rule-row span { color: #475569; font-size: 13px; } +.curated-rule-row strong { color: #111827; font-size: 13px; font-variant-numeric: tabular-nums; } +.curated-score-row { display: grid; grid-template-columns: minmax(110px, 1fr) minmax(100px, 2fr) 44px; align-items: center; gap: 10px; min-height: 38px; font-size: 12px; } +.curated-score-row > span:first-child { overflow: hidden; color: #475569; text-overflow: ellipsis; white-space: nowrap; } +.curated-score-track { height: 7px; overflow: hidden; border-radius: 4px; background: #edf0f4; } +.curated-score-track i { height: 100%; display: block; border-radius: inherit; background: #4f76c7; } +.curated-score-row strong { color: #334155; text-align: right; font-variant-numeric: tabular-nums; } +.curated-execution-bar { min-height: 70px; display: flex; align-items: center; gap: 16px; margin-top: auto; padding-top: 14px; border-top: 1px solid #e8ebef; } +.curated-data-status { min-width: 0; display: flex; align-items: center; gap: 10px; margin-right: auto; } +.curated-data-status > .lucide { width: 20px; height: 20px; color: #16a34a; } +.curated-data-status.missing > .lucide { color: #d97706; } +.curated-data-status span, +.curated-data-status strong, +.curated-data-status small { display: block; } +.curated-data-status strong { color: #334155; font-size: 13px; } +.curated-data-status small { margin-top: 3px; color: #7c8798; font-size: 12px; } + +.quant-screener-panel { grid-template-columns: minmax(0, 1fr) 330px; } +.quant-builder-pane { min-width: 0; padding: 18px 20px; } +.quant-panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.quant-universe-grid { display: grid; grid-template-columns: repeat(3, minmax(120px, 1fr)) minmax(150px, auto); gap: 10px; margin-top: 18px; padding: 14px; border: 1px solid #e5e7eb; border-radius: 7px; background: #f8fafc; } +.quant-universe-grid .form-field { margin: 0; } +.quant-universe-grid input[type="number"] { height: 40px; } +.quant-st-toggle { align-self: end; min-height: 40px; } +.quant-rule-section { margin-top: 22px; } +.icon-text-button { min-height: 36px; display: inline-flex; align-items: center; gap: 6px; padding: 0 10px; border: 1px solid #d8dde5; border-radius: 5px; background: #fff; color: #334155; font: inherit; font-size: 12px; font-weight: 650; cursor: pointer; } +.icon-text-button:hover { border-color: #9db2df; color: #1d4ed8; } +.icon-text-button .lucide { width: 14px; height: 14px; } +.quant-rule-rows { display: grid; gap: 7px; margin-top: 9px; } +.quant-rule-row { min-height: 50px; display: grid; align-items: center; gap: 8px; padding: 7px 8px; border: 1px solid #e5e7eb; border-radius: 6px; background: #fff; } +.quant-filter-row { grid-template-columns: minmax(170px, 1.6fr) minmax(92px, .7fr) minmax(110px, .9fr) 38px; } +.quant-score-row { grid-template-columns: minmax(180px, 1.5fr) minmax(120px, 1fr) minmax(110px, .8fr) 38px; } +.quant-rule-row select, +.quant-rule-row input { width: 100%; height: 38px; min-width: 0; padding: 0 9px; border: 1px solid #d8dde5; border-radius: 5px; background: #fff; color: #1f2937; font: inherit; font-size: 12px; } +.quant-rule-row select:focus, +.quant-rule-row input:focus { border-color: #2563eb; outline: 0; box-shadow: 0 0 0 3px rgba(37, 99, 235, .1); } +.quant-direction-control { height: 38px; display: grid; grid-template-columns: 1fr 1fr; padding: 2px; border-radius: 5px; background: #eef1f5; } +.quant-direction-control button { border: 0; border-radius: 4px; background: transparent; color: #64748b; font-size: 12px; cursor: pointer; } +.quant-direction-control button.active { background: #fff; color: #1d4ed8; box-shadow: 0 1px 2px rgba(15, 23, 42, .1); font-weight: 700; } +.quant-remove-button { width: 38px; height: 38px; display: grid; place-items: center; border: 0; border-radius: 5px; background: transparent; color: #94a3b8; cursor: pointer; } +.quant-remove-button:hover { background: #fff0f0; color: #dc2626; } +.quant-remove-button .lucide { width: 16px; height: 16px; } + +.quant-summary-pane { min-width: 0; padding: 20px 18px; border-left: 1px solid #e5e7eb; background: #f8fafc; } +.quant-formula-summary { display: grid; gap: 8px; margin-top: 18px; } +.quant-summary-block { padding: 10px 11px; border: 1px solid #e3e7ed; border-radius: 6px; background: #fff; } +.quant-summary-block > span { color: #64748b; font-size: 12px; } +.quant-summary-block strong { display: block; margin-top: 5px; color: #1f2937; font-size: 13px; line-height: 1.55; } +.quant-weight-status { margin: 18px 0; } +.quant-weight-status > span, +.quant-weight-status > strong { font-size: 12px; } +.quant-weight-status > strong { float: right; color: #1d4ed8; } +.quant-weight-status > div { height: 7px; margin-top: 8px; overflow: hidden; border-radius: 4px; background: #dfe4eb; } +.quant-weight-status i { width: 100%; height: 100%; display: block; border-radius: inherit; background: #2563eb; transition: width 180ms ease, background 180ms ease; } +.quant-summary-pane > .checkbox-control { min-height: 40px; margin-bottom: 10px; } +.quant-summary-pane > .button { width: 100%; min-height: 44px; justify-content: center; margin-top: 8px; } +.quant-validation-message { min-height: 38px; margin: 12px 0 0; color: #64748b; font-size: 12px; line-height: 1.55; } +.quant-validation-message.error { color: #b91c1c; } + +@media (max-width: 1050px) { + .curated-screener-panel { grid-template-columns: 290px minmax(0, 1fr); } + .curated-detail-grid { grid-template-columns: 1fr; gap: 18px; } + .quant-screener-panel { grid-template-columns: 1fr; } + .quant-summary-pane { border-top: 1px solid #e5e7eb; border-left: 0; } +} + +@media (max-width: 720px) { + #screenerView.mobile-results [data-screener-panel]:not([hidden]) { display: none !important; } + .screener-mode-tabs { align-items: stretch; padding: 0 8px; } + .screener-mode-tabs button { min-width: 0; flex: 1; padding: 0 5px; font-size: 12px; } + .curated-screener-panel, + .quant-screener-panel { min-height: 0; margin: 0 8px 8px; overflow: visible; } + .curated-screener-panel { grid-template-columns: 1fr; } + .curated-library-pane { border-right: 0; border-bottom: 1px solid #e5e7eb; } + .curated-strategy-list { grid-template-columns: 1fr 1fr; } + .curated-strategy-card { grid-template-columns: 28px minmax(0, 1fr); min-height: 70px; } + .curated-strategy-rank { width: 28px; height: 28px; } + .curated-ready-dot { display: none; } + .curated-detail-pane { padding: 16px 14px; } + .curated-detail-header { flex-direction: column; } + .curated-strategy-badges { justify-content: flex-start; } + .curated-execution-bar { align-items: stretch; flex-direction: column; } + .curated-data-status { margin-right: 0; } + .quant-builder-pane { padding: 16px 12px; } + .quant-universe-grid { grid-template-columns: 1fr 1fr; } + .quant-filter-row, + .quant-score-row { grid-template-columns: minmax(0, 1fr) 92px 38px; } + .quant-filter-row .quant-value-input, + .quant-score-row .quant-direction-control { grid-column: 1 / 3; grid-row: 2; } + .quant-remove-button { grid-column: 3; grid-row: 1 / 3; align-self: center; } +} + +@media (prefers-reduced-motion: reduce) { + .screener-mode-tabs button, + .curated-strategy-card, + .quant-weight-status i { transition: none; } +} + [hidden] { display: none !important; } @property --score { @@ -13024,3 +13218,2316 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin transition: none; } } + +/* Auction and screener page-level visual system */ +#auctionView, +#screenerView { + --action: #2563eb; + --action-hover: #1d4ed8; + --action-soft: #eff4ff; + --border: #e5e7eb; + --border-strong: #d5dae1; + --line: var(--border); + --line-strong: var(--border-strong); + --surface-muted: #f8fafc; + border-color: var(--border); + border-radius: 8px; + background: #f4f5f7; + color: #1f2937; +} + +#auctionView .section-toolbar h2, +#screenerView .section-toolbar h2 { + font-size: 17px; + font-weight: 800; +} + +/* Collection auction */ +.auction-page-header { + min-height: 76px; + align-items: center; + padding: 11px 14px; + border-bottom: 0; + background: #f4f5f7; +} + +.auction-heading-block, +.auction-header-actions { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; +} + +.auction-heading-block { flex-wrap: wrap; } +.auction-header-actions { margin-left: auto; } + +.auction-phase-notice { + min-height: 28px; + display: inline-flex; + grid-template-columns: none; + align-items: center; + gap: 6px; + padding: 4px 9px; + border: 0; + border-radius: 6px; + background: #f3f4f6; +} + +.auction-phase-notice strong, +.auction-phase-notice span:not(.auction-phase-marker), +.auction-phase-notice time { + font-size: 11px; + line-height: 1.4; +} + +.auction-phase-notice strong { color: inherit; } +.auction-phase-notice time { font-weight: 650; } +.auction-phase-notice[data-phase="observing"] { background: var(--amber-soft); color: #8a5e0a; } +.auction-phase-notice[data-phase="selection"] { background: var(--market-up-soft); color: var(--market-up); } +.auction-phase-notice[data-phase="finalized"] { background: var(--market-down-soft); color: var(--market-down); } +.auction-phase-notice[data-phase="archive"] { background: #eef2f6; color: #596574; } + +.auction-phase-marker { + width: 7px; + height: 7px; + box-shadow: none; +} + +.auction-header-summary { + display: flex; + grid-template-columns: none; + align-items: center; + gap: 22px; + border: 0; + background: transparent; +} + +.auction-header-summary > div { + min-height: 0; + align-items: flex-end; + gap: 2px; + padding: 0; + border: 0; +} + +.auction-header-summary span { font-size: 11px; } +.auction-header-summary strong { font-size: 16px; font-weight: 800; } + +.auction-workspace-layout { + min-height: 620px; + grid-template-columns: minmax(0, 1fr) 360px; + margin: 0 14px 14px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + box-shadow: 0 1px 2px rgba(16, 24, 40, .05); +} + +.auction-dataset-bar { + padding: 0 14px; + background: var(--surface); +} + +.auction-dataset-segments { + width: 100%; + height: 42px; + overflow: visible; + border: 0; + border-radius: 0; +} + +.auction-dataset-segments .segment { + min-width: 0; + flex: 0 1 auto; + padding: 0 14px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: #6b7280; + font-size: 13px; + font-weight: 550; +} + +.auction-dataset-segments .segment.active { + border-bottom-color: var(--action); + background: transparent; + color: var(--action); + font-weight: 700; +} + +.auction-dataset-segments strong { + margin-left: 3px; + color: #9ca3af; + font-weight: 500; +} + +.auction-dataset-segments .segment.active strong { color: var(--action); } + +.auction-expectation-filterbar { + min-height: 52px; + padding: 8px 14px; + background: #fff; +} + +.auction-expectation-controls { + display: flex; + align-items: center; + gap: 10px; +} + +.auction-expectation-controls > span { color: #9ca3af; font-size: 12px; } +.auction-expectation-controls[hidden] { display: none; } + +.auction-expectation-segments { + height: 32px; + gap: 2px; + padding: 2px; + overflow: visible; + border: 0; + border-radius: 8px; + background: #f3f4f6; +} + +.auction-expectation-segments .segment { + min-width: 58px; + padding: 0 12px; + border: 0; + border-radius: 6px; + background: transparent; + color: #6b7280; + font-size: 12px; +} + +.auction-expectation-segments .segment.active { + background: #fff; + color: #1f2937; + box-shadow: 0 1px 2px rgba(0, 0, 0, .08); + font-weight: 700; +} + +.auction-expectation-filterbar .search-field { margin-left: auto; } +.auction-expectation-filterbar .search-field input { + width: min(250px, 25vw); + height: 34px; + border-color: var(--border); + border-radius: 7px; + background: #fff; + font-size: 12px; +} + +.auction-unified-table-frame { + min-height: 470px; + max-height: calc(100vh - 286px); +} + +.auction-table { + min-width: 860px; + font-size: 12.5px; +} + +.auction-table th, +.auction-table td { + height: 44px; + padding-right: 12px; + padding-left: 12px; + border-right: 0; + border-bottom-color: #eef0f3; +} + +.auction-table th { + height: 38px; + background: #f8fafc; + color: #6b7280; + font-size: 12px; +} + +.auction-table tbody tr:hover td { background: #f8faff; } +.auction-stock-cell { grid-template-columns: minmax(0, 1fr); min-width: 112px; } +.auction-stock-cell > b { display: none; } +.auction-stock-cell strong { font-size: 13px; font-weight: 750; } +.auction-stock-cell small { margin-left: 6px; font-size: 10.5px; } +.auction-stock-cell > span { display: flex; align-items: baseline; gap: 0; } +.auction-context-cell strong { font-size: 12px; } +.auction-volume-ratio { color: #6b7280; } + +.auction-evidence-rail { + border-left-color: var(--border); + background: #f4f5f7; +} + +.auction-evidence-section { + padding: 12px 14px; + border-bottom-color: var(--border); +} + +.auction-evidence-section .auction-insight-heading h3 { font-size: 14px; font-weight: 750; } +.auction-evidence-section .auction-theme-row { min-height: 43px; border-bottom-style: dashed; } +.auction-evidence-section .auction-theme-row > div:first-child strong { font-size: 13px; } +.auction-evidence-section .auction-amount-trend { height: 128px; position: relative; } +.auction-amount-day > span { background: #c9d6ee; } +.auction-amount-average { + position: absolute; + right: 0; + left: 0; + z-index: 2; + border-top: 1.5px dashed #d97706; + pointer-events: none; +} +.auction-amount-average small { + position: absolute; + top: -16px; + right: 0; + padding-left: 4px; + background: #fff; + color: #b45309; + font-size: 10px; +} + +.auction-news-entry { min-height: 88px; background: #fff; } + +/* Smart screener */ +.screener-page-heading { + min-height: 58px; + border-bottom: 0; + background: #f4f5f7; +} + +#screenerView .screener-strategy-view { padding: 0 14px 14px; } + +.screener-stepper { + min-height: 60px; + display: flex; + align-items: center; + padding: 10px 18px; + border: 1px solid var(--border); + border-radius: 10px; + background: #fff; + box-shadow: 0 1px 2px rgba(16, 24, 40, .05); +} + +.screener-step { + min-width: 0; + display: flex; + align-items: center; + gap: 9px; +} + +.screener-step > div { min-width: 0; } +.screener-step strong, +.screener-step small { display: block; white-space: nowrap; } +.screener-step strong { font-size: 13px; } +.screener-step small { margin-top: 2px; overflow: hidden; color: #9ca3af; font-size: 11px; text-overflow: ellipsis; } + +.step-marker { + width: 24px; + height: 24px; + display: grid; + place-items: center; + flex: 0 0 24px; + border-radius: 50%; + background: #e5e7eb; + color: #9ca3af; + font-size: 11px; + font-weight: 750; +} + +.screener-step[data-state="complete"] .step-marker { + background: #16a34a; + color: transparent; + font-size: 0; +} + +.screener-step[data-state="complete"] .step-marker::after { content: "\2713"; color: #fff; font-size: 12px; } +.screener-step[data-state="current"] .step-marker { background: var(--action); color: #fff; } +.screener-step[data-state="current"] small { color: var(--action); } + +.step-line { + height: 2px; + min-width: 30px; + flex: 1; + margin: 0 14px; + background: #e5e7eb; +} +.step-line.complete { background: #b9dec9; } + +.screener-overview-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 12px; + margin-top: 12px; +} + +.screener-overview-card { + min-width: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 10px; + background: #fff; + box-shadow: 0 1px 2px rgba(16, 24, 40, .05); +} + +.screener-card-heading { + min-height: 43px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 9px 14px; + border-bottom: 1px solid #eef0f3; +} +.screener-card-heading h3 { margin: 0; font-size: 14px; } +.screener-soft-label { padding: 3px 7px; border-radius: 5px; background: #f3f4f6; color: #6b7280; font-size: 10.5px; } + +.screener-regime-body { + min-height: 90px; + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + align-items: center; + gap: 14px; + padding: 12px 14px 8px; +} + +#screenerView .regime-summary { + min-height: 66px; + align-items: center; + padding: 9px 12px; + border: 1px solid #f3c7c3; + border-radius: 9px; + background: var(--market-up-soft); + text-align: center; +} +#screenerView .regime-summary strong { color: var(--market-up); font-size: 19px; font-weight: 800; } +#screenerView .regime-summary span { color: #6b7280; font-size: 11px; } + +#screenerView .regime-evidence { + min-width: 0; + gap: 4px; + padding: 0; + border: 0; +} +#screenerView .regime-evidence strong { font-size: 12.5px; } +#screenerView .regime-evidence div { max-height: 40px; overflow: hidden; color: #6b7280; font-size: 11.5px; line-height: 1.65; } + +#screenerView .regime-selector { + display: flex; + flex-wrap: wrap; + gap: 5px; + padding: 4px 14px 10px; + border: 0; +} +#screenerView .regime-option { + width: auto; + height: 28px; + padding: 0 11px; + border-color: var(--border); + border-radius: 6px; + color: #6b7280; + font-size: 11.5px; +} +#screenerView .regime-option.active { border-color: var(--market-up); background: var(--market-up-soft); color: var(--market-up); } + +#screenerView .factor-data-status { + min-height: 34px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 7px; + padding: 7px 14px; + border: 0; + border-top: 1px solid #eef0f3; + color: #9ca3af; + font-size: 10.5px; +} +#screenerView .factor-data-status strong { color: #16a34a; font-size: 11px; } +#screenerView .factor-data-status small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.screener-strategy-summary { min-height: 164px; display: flex; flex-direction: column; padding: 14px 16px; } +.screener-strategy-title { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } +.screener-strategy-title > strong { font-size: 16px; } +.screener-strategy-title > span { display: inline-flex; gap: 4px; flex-wrap: wrap; } +.screener-strategy-title b { padding: 2px 7px; border-radius: 5px; background: var(--market-up-soft); color: var(--market-up); font-size: 10.5px; } +.screener-strategy-title b.neutral { background: #f3f4f6; color: #6b7280; } +.screener-strategy-summary p { margin: 10px 0 0; color: #6b7280; font-size: 12px; line-height: 1.75; } +.screener-strategy-actions { display: flex; gap: 8px; margin-top: auto; padding-top: 12px; } + +.screener-runbar { + min-height: 56px; + display: flex; + align-items: center; + gap: 12px; + margin-top: 12px; + padding: 9px 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: #fff; +} +.screener-run-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.screener-pipeline-status { display: flex; gap: 16px; margin-left: auto; color: #6b7280; font-size: 11px; } +.screener-pipeline-status strong { margin-left: 3px; color: #16a34a; font-weight: 650; } + +#screenerView .screener-backtest-strip { + min-height: 62px; + display: grid; + grid-template-columns: 18px minmax(360px, auto) minmax(240px, 1fr); + align-items: center; + gap: 18px; + margin-top: 12px; + padding: 8px 14px; + border: 1px solid #f0dfc1; + border-radius: 10px 10px 0 0; + background: #fffaf3; +} +.screener-backtest-strip > .lucide { width: 16px; height: 16px; color: #b45309; } +#screenerView .screener-backtest-strip .dragon-summary { display: flex; border: 0; background: transparent; } +#screenerView .screener-backtest-strip .dragon-metric { min-width: 96px; padding: 2px 16px 2px 0; border: 0; } +#screenerView .screener-backtest-strip .dragon-metric span { font-size: 10.5px; } +#screenerView .screener-backtest-strip .dragon-metric strong { margin-top: 2px; font-size: 15px; } +.screener-backtest-strip > p { margin: 0; color: #9ca3af; font-size: 10.5px; line-height: 1.55; } + +#screenerView .screener-results-view, +#screenerView .strategy-tracking-panel { + margin: 0 14px 14px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 10px; + background: #fff; + box-shadow: 0 1px 2px rgba(16, 24, 40, .05); +} +#screenerView .screener-results-view { margin-top: 0; } +#screenerView .screener-result-frame { min-height: 420px; border: 0; } +#screenerView .screener-result-frame .data-table { font-size: 12.5px; } +#screenerView .screener-result-frame th, +#screenerView .screener-result-frame td { height: 44px; padding-right: 12px; padding-left: 12px; border-right: 0; border-bottom-color: #eef0f3; } +#screenerView .screener-result-frame th { height: 38px; background: #f8fafc; color: #6b7280; font-size: 12px; } +#screenerView .screener-result-frame thead th:nth-child(-n+3) { background: #f8fafc; } + +.strategy-drawer { + width: min(560px, 100vw); + max-width: none; + height: 100dvh; + max-height: none; + margin: 0 0 0 auto; + padding: 0; + overflow: hidden; + border: 0; + border-left: 1px solid var(--border); + border-radius: 0; + background: #fff; + box-shadow: -12px 0 32px rgba(15, 23, 42, .16); +} +.strategy-drawer[open] { display: flex; flex-direction: column; animation: strategy-drawer-enter 240ms var(--ease-out) both; } +.strategy-drawer::backdrop { background: rgba(15, 23, 42, .38); } +@keyframes strategy-drawer-enter { from { opacity: 0; transform: translateX(28px); } to { opacity: 1; transform: translateX(0); } } + +.strategy-drawer-header { + min-height: 66px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 16px; + border-bottom: 1px solid var(--border); +} +.strategy-drawer-header span { color: #9ca3af; font-size: 10.5px; } +.strategy-drawer-header h2 { margin: 3px 0 0; font-size: 16px; } +.strategy-drawer-header .icon-button { width: 38px; height: 38px; } +.strategy-drawer-body { min-height: 0; flex: 1; overflow-y: auto; } + +#screenerView .strategy-drawer .strategy-sidebar { + max-height: 210px; + padding: 12px 14px; + overflow-y: auto; + border: 0; + border-bottom: 1px solid var(--border); + background: #f8f9fb; +} +#screenerView .strategy-drawer .strategy-list { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; } +#screenerView .strategy-drawer .strategy-item { min-height: 64px; padding: 8px 9px; border-radius: 6px; background: #fff; } +#screenerView .strategy-drawer .strategy-workbench { padding: 14px 16px 18px; } +#screenerView .strategy-drawer .strategy-meta-fields { grid-template-columns: 1fr; } +#screenerView .strategy-drawer .strategy-prompt-field textarea { min-height: 110px; } +#screenerView .strategy-drawer .strategy-actions { display: flex; flex-wrap: wrap; } +#screenerView .strategy-drawer .strategy-actions .checkbox-control { width: 100%; margin-bottom: 2px; } +#screenerView .strategy-drawer .form-field.formula-field textarea { min-height: 190px; } + +@media (max-width: 1200px) { + .auction-page-header { align-items: flex-start; flex-direction: column; } + .auction-header-actions { width: 100%; margin-left: 0; justify-content: space-between; } + .auction-workspace-layout { grid-template-columns: minmax(0, 1fr) 320px; } + .screener-step small { max-width: 150px; } + .step-line { margin: 0 8px; } + #screenerView .screener-backtest-strip { grid-template-columns: 18px minmax(360px, 1fr); } + .screener-backtest-strip > p { grid-column: 2; } +} + +@media (max-width: 900px) { + .auction-workspace-layout { grid-template-columns: 1fr; } + .auction-evidence-rail { border-top: 1px solid var(--border); border-left: 0; } + .screener-overview-grid { grid-template-columns: 1fr; } + .screener-stepper { align-items: flex-start; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } + .screener-step { align-items: flex-start; } + .step-line { display: none; } +} + +@media (max-width: 720px) { + #auctionView, + #screenerView { border-radius: 0; } + .auction-page-header { min-height: 0; padding: 10px 12px; } + .auction-heading-block { align-items: flex-start; flex-direction: column; gap: 8px; } + .auction-phase-notice { max-width: 100%; flex-wrap: wrap; } + .auction-phase-notice span:not(.auction-phase-marker) { flex: 1 1 calc(100% - 16px); } + .auction-header-summary { width: 100%; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 12px; } + .auction-header-summary > div { align-items: flex-start; } + .auction-workspace-layout { margin: 0 8px 8px; border-radius: 8px; } + .auction-dataset-bar { overflow-x: auto; padding: 0 10px; } + .auction-dataset-segments { min-width: 430px; } + .auction-dataset-segments .segment { min-height: 44px; padding: 0 11px; } + .auction-expectation-filterbar { align-items: stretch; flex-direction: column; padding: 8px 10px; } + .auction-expectation-controls { align-items: flex-start; flex-direction: column; gap: 6px; } + .auction-expectation-segments { width: 100%; height: 42px; overflow-x: auto; } + .auction-expectation-segments .segment { min-height: 38px; flex: 1 0 auto; } + .auction-expectation-filterbar .search-field { width: 100%; margin-left: 0; } + .auction-expectation-filterbar .search-field input { width: 100%; height: 44px; font-size: 16px; } + .auction-unified-table-frame { min-height: 360px; max-height: none; } + + .screener-page-heading { min-height: 54px; padding: 9px 12px; } + #screenerView .screener-strategy-view { padding: 0 8px 8px; } + .screener-stepper { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 10px 12px; } + .screener-step { min-height: 46px; } + .screener-step small { max-width: calc(50vw - 64px); } + .screener-overview-grid { gap: 8px; margin-top: 8px; } + .screener-regime-body { grid-template-columns: 92px minmax(0, 1fr); gap: 10px; padding: 10px; } + #screenerView .regime-selector { padding: 4px 10px 10px; } + #screenerView .regime-option { min-height: 36px; flex: 1 0 calc(33.333% - 5px); padding: 0 4px; } + #screenerView .factor-data-status { padding: 8px 10px; } + .screener-strategy-summary { min-height: 150px; padding: 12px; } + .screener-strategy-actions { display: grid; grid-template-columns: 1fr 1fr; } + .screener-strategy-actions .button { min-width: 0; min-height: 42px; padding: 0 7px; } + .screener-runbar { align-items: stretch; flex-direction: column; margin-top: 8px; padding: 10px; } + .screener-run-actions { display: grid; grid-template-columns: 1fr 1fr; } + .screener-run-actions .button { min-height: 42px; justify-content: center; } + .screener-pipeline-status { justify-content: space-between; margin-left: 0; padding-top: 5px; border-top: 1px solid #eef0f3; } + #screenerView .screener-backtest-strip { grid-template-columns: 16px minmax(0, 1fr); gap: 10px; margin-top: 8px; padding: 10px; } + #screenerView .screener-backtest-strip .dragon-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } + #screenerView .screener-backtest-strip .dragon-metric { min-width: 0; padding: 5px 8px 5px 0; } + .screener-backtest-strip > p { grid-column: 1 / -1; } + #screenerView .screener-results-view, + #screenerView .strategy-tracking-panel { margin: 0 8px 8px; border-radius: 8px; } + .strategy-drawer { width: 100vw; } + #screenerView .strategy-drawer .strategy-list { grid-template-columns: 1fr; } + #screenerView .strategy-drawer .strategy-sidebar { max-height: 190px; } + #screenerView .strategy-drawer .strategy-actions .button { min-height: 42px; } +} + +@media (prefers-reduced-motion: reduce) { + .strategy-drawer[open] { animation: none; } + .auction-table tbody tr td, + #screenerView .screener-result-frame tbody tr td { transition: none; } +} + +/* Unified visual system 2026-07 */ +:root { + --canvas: #f1f4f6; + --surface: #ffffff; + --surface-muted: #f6f8fa; + --surface-subtle: #fafbfc; + --border: #dfe4e9; + --border-strong: #cbd3dc; + --text-primary: #17202a; + --text-secondary: #607080; + --text-tertiary: #8995a2; + --action: #1769c2; + --action-hover: #10569f; + --action-soft: #eaf2fb; + --market-up: #d33f49; + --market-up-soft: #fff0f1; + --market-down: #07805b; + --market-down-soft: #eaf7f2; + --warning-color: #aa6800; + --warning-soft: #fff6e5; + --bg: var(--canvas); + --line: var(--border); + --line-strong: var(--border-strong); + --text: var(--text-primary); + --text-muted: var(--text-secondary); + --blue: var(--action); + --blue-dark: var(--action-hover); + --blue-soft: var(--action-soft); + --coral: var(--market-up); + --coral-soft: var(--market-up-soft); + --green: var(--market-down); + --green-soft: var(--market-down-soft); + --amber: var(--warning-color); + --amber-soft: var(--warning-soft); + --shadow-soft: 0 1px 2px rgba(22, 34, 46, .04), 0 5px 18px rgba(22, 34, 46, .035); + --shadow: 0 18px 50px rgba(22, 34, 46, .18); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", "PingFang SC", "Microsoft YaHei UI", sans-serif; + font-size: 14px; +} + +html, +body { + background: var(--canvas); + color: var(--text-primary); +} + +body { + grid-template-columns: 216px minmax(0, 1fr); + grid-template-rows: 64px minmax(0, 1fr) 28px; +} + +button, +input, +select, +textarea { + letter-spacing: 0; +} + +.app-header { + min-height: 64px; + height: 64px; + grid-template-columns: 216px minmax(0, 1fr); + gap: 16px; + padding: 0 16px 0 14px; + border-bottom: 1px solid var(--border); + background: rgba(255, 255, 255, .97); + box-shadow: 0 1px 0 rgba(22, 34, 46, .03); + backdrop-filter: saturate(150%) blur(10px); +} + +.brand-block { + gap: 10px; +} + +.brand-block .brand-mark, +.brand-block .brand-logo { + width: 38px; + height: 38px; +} + +.brand-block .brand-mark { + flex-basis: 38px; + border: 0; + background: transparent; +} + +.brand-block h1 { + font-size: 18px; + font-weight: 760; +} + +.market-tape { + display: none; +} + +.header-actions { + grid-column: 2; + justify-self: end; + gap: 7px; +} + +.header-date-group { + height: 38px; + padding: 2px 3px; + border-color: var(--border); + border-radius: 7px; + background: var(--surface-muted); +} + +.header-date-group .date-input { + width: 132px; + height: 32px; + font-size: 12.5px; +} + +.header-date-group .icon-button { + width: 30px; + min-height: 30px; +} + +.button, +.icon-button, +.date-input, +.search-field input, +.form-field input, +.form-field select, +.form-field textarea { + border-color: var(--border-strong); + border-radius: 6px; +} + +.button, +.icon-button { + min-height: 36px; + box-shadow: none; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 13px; + color: #334150; + font-size: 13px; + font-weight: 620; +} + +.button:hover, +.icon-button:hover { + border-color: #94b5d8; + background: #f8fbff; + color: var(--action-hover); + box-shadow: none; +} + +.button.primary { + border-color: var(--action); + background: var(--action); +} + +.button.primary:hover { + border-color: var(--action-hover); + background: var(--action-hover); +} + +.icon-button { + width: 36px; + color: #445363; +} + +.icon-button .lucide, +.button .lucide { + width: 16px; + height: 16px; + stroke-width: 1.8; +} + +.command-button { + min-height: 36px; +} + +.account-role-badge, +.account-button { + min-height: 34px; +} + +.module-nav { + width: 216px; + height: calc(100vh - 64px); + top: 64px; + padding: 8px 10px 12px; + border-right-color: var(--border); + background: #fbfcfd; +} + +.nav-brand { + min-height: 36px; + padding: 0 10px; + color: var(--text-tertiary); + font-size: 10.5px; + letter-spacing: 0; +} + +.nav-group + .nav-group { + margin-top: 14px; + padding-top: 11px; + border-top: 1px solid #edf0f3; +} + +.nav-group-label { + height: 24px; + padding: 0 10px; + color: var(--text-tertiary); + font-size: 10.5px; + font-weight: 680; +} + +.module-tab { + min-height: 40px; + gap: 11px; + padding: 0 11px; + border-radius: 6px; + color: #4c5967; + font-size: 13px; + font-weight: 610; +} + +.module-tab .lucide { + width: 17px; + height: 17px; + flex: 0 0 17px; + stroke-width: 1.7; +} + +.module-tab:hover { + background: #f0f3f6; + color: var(--text-primary); +} + +.module-tab.active { + position: relative; + background: var(--action-soft); + color: var(--action); +} + +.module-tab.active::before { + content: ""; + width: 3px; + position: absolute; + inset: 8px auto 8px 0; + border-radius: 0 3px 3px 0; + background: var(--action); +} + +.sidebar-collapse-button { + min-height: 38px; + margin-top: auto; + border-top: 1px solid #edf0f3; + border-radius: 0; + color: var(--text-tertiary); +} + +.app-main { + padding: 18px 18px 24px; +} + +.status-bar { + height: 28px; + min-height: 28px; + border-top-color: var(--border); + background: #fbfcfd; + color: var(--text-tertiary); + font-size: 10.5px; +} + +.overview-strip { + min-height: 82px; + grid-template-columns: minmax(230px, 1.45fr) repeat(5, minmax(105px, .78fr)) minmax(190px, 1.1fr); + border-color: var(--border); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow-soft); +} + +.sentiment-block, +.metric { + padding: 11px 15px; + border-right-color: var(--border); +} + +.sentiment-gauge { + width: 52px; + height: 52px; + flex-basis: 52px; +} + +.sentiment-gauge span { + font-size: 17px; +} + +.metric-label { + color: var(--text-secondary); + font-size: 11px; +} + +.sentiment-text { + font-size: 16px; + font-weight: 760; +} + +.metric-value { + font-size: 20px; + font-weight: 760; +} + +.metric-value.small { + font-size: 14px; +} + +body[data-active-view="screenerView"] .overview-strip, +body[data-active-view="mentorView"] .overview-strip, +body[data-active-view="heavenView"] .overview-strip, +body[data-active-view="reviewWorkspaceView"] .overview-strip { + display: none; +} + +body[data-active-view="screenerView"] .workspace-view, +body[data-active-view="mentorView"] .workspace-view, +body[data-active-view="heavenView"] .workspace-view, +body[data-active-view="reviewWorkspaceView"] .workspace-view { + margin-top: 0; +} + +.workspace-view { + margin-top: 14px; + overflow: hidden; + border-color: var(--border); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow-soft); +} + +.section-toolbar { + min-height: 58px; + padding: 10px 14px; + border-bottom-color: var(--border); + background: var(--surface); +} + +.section-title-group { + gap: 9px; + min-width: 0; +} + +.section-toolbar h2, +.section-title-group h2 { + color: var(--text-primary); + font-size: 18px; + font-weight: 770; +} + +.section-subtitle { + color: var(--text-secondary); + font-size: 11.5px; +} + +.count-badge { + min-height: 24px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border: 0; + border-radius: 5px; + background: var(--market-up-soft); + color: var(--market-up); + font-size: 11.5px; + font-weight: 720; +} + +.toolbar-controls { + gap: 8px; +} + +.segmented { + min-height: 36px; + border-color: var(--border-strong); + border-radius: 6px; + background: var(--surface); +} + +.segment { + min-height: 34px; + padding: 0 13px; + border-color: var(--border); + color: #425160; + font-size: 12.5px; +} + +.segment.active { + background: var(--action); + color: #fff; +} + +.search-field input { + height: 36px; + background: var(--surface); + color: var(--text-primary); + font-size: 12.5px; +} + +.table-frame { + border-color: var(--border); + border-radius: 0; + background: var(--surface); +} + +.data-table { + color: #263340; + font-size: 13px; +} + +.data-table th, +.data-table td { + height: 42px; + padding: 8px 11px; + border-right: 0; + border-bottom: 1px solid #e8edf1; + line-height: 1.4; +} + +.data-table th { + height: 38px; + background: #f4f7f9; + color: #526273; + font-size: 11.5px; + font-weight: 700; +} + +.data-table tbody tr:hover td { + background: #f6faff; +} + +.data-table tbody tr.selected td { + background: #edf5ff; +} + +.empty-state { + min-height: 170px; + display: grid; + place-items: center; + padding: 24px; + color: var(--text-tertiary); + font-size: 13px; +} + +.inline-notice { + border-radius: 6px; + font-size: 12px; +} + +.workspace-heading h3, +.rail-heading h3, +.mini-section-heading h4 { + color: var(--text-primary); + font-weight: 740; +} + +dialog { + border-color: var(--border); + border-radius: 9px; + box-shadow: var(--shadow); +} + +dialog::backdrop { + background: rgba(27, 38, 49, .48); + backdrop-filter: blur(2px); +} + +.dialog-header { + min-height: 62px; + border-bottom-color: var(--border); +} + +.form-field > span { + color: #334150; + font-size: 12.5px; + font-weight: 670; +} + +/* Market workspaces */ +.main-grid { + grid-template-columns: minmax(0, 1fr) 286px; + min-height: 540px; +} + +.main-grid > .table-frame { + border-right: 1px solid var(--border); +} + +.insight-rail { + background: var(--surface-subtle); +} + +.rail-section { + padding: 15px 14px; + border-bottom-color: var(--border); +} + +.rail-heading { + margin-bottom: 11px; +} + +.mini-row { + min-height: 36px; + border-color: var(--border); + border-radius: 5px; + box-shadow: none; +} + +.sector-mini-row { + min-height: 28px; +} + +.phase-table-frame { + min-height: 530px; + border: 0; +} + +.performance-cards { + gap: 0; + padding: 0; + border-bottom: 1px solid var(--border); +} + +.performance-card { + min-height: 124px; + padding: 15px 16px; + border-color: var(--border); + background: var(--surface); +} + +.performance-rate strong { + font-size: 27px; +} + +.market-breadth-panel { + padding: 16px; + border-bottom-color: var(--border); + background: var(--surface-subtle); +} + +.breadth-metrics > div { + border-color: var(--border); +} + +.sentiment-cycle-summary { + grid-template-columns: minmax(270px, 1.25fr) repeat(3, minmax(160px, 1fr)); + border-bottom-color: var(--border); +} + +.sentiment-cycle-current, +.sentiment-cycle-state { + min-height: 104px; + padding: 15px 17px; + border-right-color: var(--border); +} + +.sentiment-cycle-score-marker strong { + font-size: 30px; +} + +.sentiment-cycle-analysis { + grid-template-columns: minmax(0, 1.7fr) minmax(300px, .75fr); + border-bottom-color: var(--border); +} + +.sentiment-trend-panel, +.sentiment-components-panel { + padding: 16px; +} + +.sentiment-trend-panel { + border-right-color: var(--border); +} + +.sentiment-chart-shell { + height: 290px; +} + +.sentiment-component-item { + border-bottom-color: var(--border); +} + +.sentiment-history-frame { + border: 0; +} + +.sentiment-history-table th, +.sentiment-history-table td { + text-align: center; +} + +.sentiment-history-groups th { + background: #eef3f6; +} + +.ladder-board { + max-width: 1120px; + margin: 0 auto; + padding: 18px 18px 20px; +} + +.ladder-level { + border-color: var(--border); + border-radius: 6px; + box-shadow: 0 1px 3px rgba(22, 34, 46, .04); +} + +.rotation-history-panel { + border-bottom-color: var(--border); +} + +.rotation-history-heading { + min-height: 58px; + padding: 11px 14px; +} + +.rotation-history { + border-top-color: var(--border); +} + +.rotation-day { + padding: 11px 8px 13px; + border-right-color: var(--border); + background: var(--surface-subtle); +} + +.rotation-day:nth-child(odd) { + background: #f6f8fa; +} + +.rotation-day-sector { + border-color: #e7ebef; + border-radius: 4px; + background: #fff; +} + +/* Market discovery */ +.market-feature-summary { + border-bottom-color: var(--border); +} + +.market-feature-summary > div { + min-height: 68px; + padding: 11px 14px; + border-right-color: var(--border); +} + +.market-feature-summary span { + font-size: 11px; +} + +.market-feature-summary strong { + font-size: 18px; +} + +.market-feature-filterbar, +.dragon-filterbar { + min-height: 54px; + padding: 8px 14px; + border-bottom-color: var(--border); + background: var(--surface-subtle); +} + +#auctionView, +#screenerView { + --action: #1769c2; + --action-hover: #10569f; + --action-soft: #eaf2fb; + --border: #dfe4e9; + --border-strong: #cbd3dc; + border-color: var(--border); + background: var(--surface); +} + +.auction-page-header, +.screener-page-heading { + min-height: 62px; + padding: 10px 14px; + background: var(--surface); +} + +.auction-workspace-layout { + min-height: 620px; + grid-template-columns: minmax(0, 1fr) 340px; + margin: 0; + border: 0; + border-top: 1px solid var(--border); + border-radius: 0; + box-shadow: none; +} + +.auction-evidence-rail { + border-left-color: var(--border); + background: var(--surface-subtle); +} + +.auction-evidence-section { + border-bottom-color: var(--border); +} + +.auction-table th, +.auction-table td { + border-bottom-color: #e8edf1; +} + +.auction-table th { + background: #f4f7f9; +} + +.auction-phase-notice { + border-radius: 5px; +} + +.theme-library-layout { + grid-template-columns: 300px minmax(0, 1fr); + min-height: 680px; +} + +.theme-directory-panel { + border-right-color: var(--border); + background: var(--surface-subtle); +} + +.theme-directory-heading, +.theme-members-heading { + min-height: 48px; + padding: 9px 13px; + border-bottom-color: var(--border); +} + +.theme-directory-item { + min-height: 60px; + padding: 9px 13px; + border-bottom-color: #e7ebef; +} + +.theme-directory-item:hover { + background: #f1f6fb; +} + +.theme-directory-item.active { + background: #eaf2fb; + box-shadow: inset 3px 0 var(--action); +} + +.theme-detail-heading { + min-height: 78px; + border-bottom-color: var(--border); +} + +.theme-detail-metrics, +.theme-detail-metrics > div, +.theme-chart-shell { + border-color: var(--border); +} + +.theme-chart-shell { + height: 310px; + padding: 10px 14px; +} + +.dragon-summary { + border-bottom-color: var(--border); +} + +.dragon-metric { + border-right-color: var(--border); +} + +.dragon-card-stage { + border-bottom-color: var(--border); + background: var(--surface-subtle); +} + +.dragon-stage-heading { + min-height: 50px; + padding: 9px 14px; +} + +.dragon-trader-detail { + min-height: 310px; +} + +.trader-profile { + border-bottom-color: var(--border); +} + +/* Intelligent tools */ +.screener-mode-tabs { + min-height: 50px; + align-items: center; + padding: 0 14px; + border-bottom-color: var(--border); + background: var(--surface); +} + +.screener-mode-tabs button { + min-height: 48px; + border-bottom-width: 2px; + color: var(--text-secondary); + font-size: 13px; +} + +.screener-mode-tabs button.active { + border-bottom-color: var(--action); + color: var(--action); +} + +#screenerView .screener-strategy-view { + padding: 14px; + background: var(--canvas); +} + +.screener-stepper, +.screener-overview-card, +.screener-runbar, +#screenerView .screener-results-view, +#screenerView .strategy-tracking-panel, +.curated-screener-panel, +.quant-screener-panel { + border-color: var(--border); + border-radius: 7px; + box-shadow: var(--shadow-soft); +} + +.screener-stepper { + min-height: 64px; +} + +.screener-overview-card { + border-radius: 7px; +} + +.screener-card-heading { + border-bottom-color: var(--border); +} + +.screener-runbar { + min-height: 60px; +} + +#screenerView .screener-results-view, +#screenerView .strategy-tracking-panel { + margin: 0 14px 14px; +} + +.curated-screener-panel, +.quant-screener-panel { + min-height: 590px; + margin: 14px; +} + +.curated-screener-panel { + height: min(720px, calc(100vh - 220px)); + min-height: 600px; +} + +.curated-library-pane { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.curated-strategy-list { + min-height: 0; + flex: 1; + overflow-y: auto; + padding-right: 2px; + scrollbar-width: thin; +} + +.curated-library-pane, +.quant-summary-pane { + border-color: var(--border); + background: var(--surface-subtle); +} + +.curated-strategy-card, +.quant-rule-row, +.quant-summary-block { + border-color: var(--border); + border-radius: 5px; + box-shadow: none; +} + +.curated-strategy-card.active { + border-color: #9bbce0; + background: #edf5ff; + box-shadow: inset 3px 0 var(--action); +} + +.quant-universe-grid { + border-color: var(--border); + background: var(--surface-subtle); +} + +.quant-rule-row select, +.quant-rule-row input { + border-color: var(--border-strong); +} + +.mentor-layout { + height: calc(100vh - 110px); + min-height: 670px; + grid-template-columns: 320px minmax(0, 1fr); + background: var(--surface); +} + +.mentor-sidebar { + border-right-color: var(--border); + background: var(--surface-subtle); +} + +.mentor-directory-heading, +.mentor-chat-header, +.mentor-quick-prompts, +.mentor-chat-form { + border-color: var(--border); +} + +.mentor-option { + border-bottom-color: #e7ebef; +} + +.mentor-option:hover { + background: #f1f6fb; +} + +.mentor-option.active { + background: #eaf2fb; +} + +.mentor-chat-panel { + background: var(--surface); +} + +.mentor-chat-header { + min-height: 102px; + padding: 14px 18px; +} + +.mentor-messages { + padding: 20px; + background: #fbfcfd; +} + +.mentor-chat-form { + padding: 12px 18px; +} + +/* Review workspace */ +.review-workspace { + grid-template-columns: minmax(360px, .95fr) minmax(520px, 1.05fr); + background: var(--surface); +} + +.workspace-section { + border-color: var(--border); + background: var(--surface); +} + +.workspace-heading { + min-height: 54px; + padding: 10px 16px; + border-bottom-color: var(--border); +} + +.workspace-table-frame, +.trade-log-table-frame { + border: 0; +} + +.journal-form { + padding: 15px 16px 16px; +} + +.journal-form textarea { + min-height: 104px; + padding: 10px; + border-color: var(--border-strong); + background: #fff; + font-size: 13px; + line-height: 1.65; +} + +.trade-journal-section, +.notes-history-section { + border-top-color: var(--border); +} + +.trade-log-summary { + border-bottom-color: var(--border); +} + +/* Keep Wentian distinctive while aligning its outer frame */ +#heavenView { + border-color: #ded9ce; + box-shadow: var(--shadow-soft); +} + +#heavenView .heaven-toolbar { + min-height: 66px; + padding: 12px 20px; +} + +#heavenView .heaven-tabs { + min-height: 54px; + padding: 0 20px; +} + +#heavenView .heaven-proverb { + padding: 10px 20px; +} + +#heavenView .heaven-panel { + padding-right: 20px; + padding-left: 20px; +} + +#heavenView .heaven-controls { + min-height: 96px; + gap: 16px; +} + +.heaven-trend-empty { + min-height: 340px; +} + +/* Dialogs and overlays */ +.global-search-dialog, +.assistant-dialog, +.settings-dialog, +.stock-dialog, +.account-settings-dialog, +.heaven-reading-dialog { + border-radius: 9px; +} + +.global-search-dialog { + max-width: 700px; +} + +.assistant-dialog { + max-width: 820px; +} + +.assistant-dialog .assistant-messages { + background: #fbfcfd; +} + +.account-dropdown { + border-color: var(--border); + border-radius: 7px; + box-shadow: 0 14px 35px rgba(22, 34, 46, .15); +} + +@media (min-width: 721px) and (max-width: 1279px) { + body { + grid-template-columns: 68px minmax(0, 1fr); + } + + .app-header { + grid-template-columns: 68px minmax(0, 1fr); + padding-left: 8px; + } + + .brand-block h1 { + display: none; + } + + .module-nav, + body.sidebar-collapsed .module-nav { + width: 68px; + padding: 8px 7px 12px; + } + + .nav-brand, + .nav-group-label, + .module-tab span, + .sidebar-collapse-button span { + display: none; + } + + .module-tab, + .sidebar-collapse-button { + justify-content: center; + padding: 0; + } + + .module-tab.active::before { + inset: 8px auto 8px -7px; + } + + .overview-strip { + grid-template-columns: minmax(210px, 1.3fr) repeat(5, minmax(90px, .7fr)); + } + + .overview-strip .metric-wide { + display: none; + } + + .auction-workspace-layout { + grid-template-columns: minmax(0, 1fr) 300px; + } + + .mentor-layout { + grid-template-columns: 280px minmax(0, 1fr); + } +} + +@media (max-width: 720px) { + html, + body { + min-width: 320px; + width: 100%; + } + + body, + body.sidebar-collapsed { + display: block; + min-height: 100dvh; + padding-bottom: calc(68px + env(safe-area-inset-bottom)); + } + + .app-header { + width: 100%; + height: 108px; + min-height: 108px; + position: relative; + display: flex; + align-items: flex-start; + padding: 8px 10px 0; + border-bottom-color: var(--border); + background: rgba(255, 255, 255, .98); + } + + .brand-block { + height: 42px; + } + + .brand-block .brand-mark, + .brand-block .brand-logo { + width: 34px; + height: 34px; + } + + .brand-block .brand-mark { + flex-basis: 34px; + } + + .brand-block h1 { + display: block; + font-size: 16px; + } + + .header-actions { + position: absolute; + inset: 56px 10px auto; + display: flex; + justify-content: space-between; + gap: 6px; + } + + .header-date-group { + height: 42px; + min-width: 0; + flex: 1; + } + + .header-date-group .date-input { + width: 112px; + flex: 1; + font-size: 11.5px; + } + + .header-date-group .icon-button { + width: 30px; + min-width: 30px; + } + + .header-actions > .icon-button { + width: 40px; + min-width: 40px; + min-height: 42px; + } + + .header-menu-button { + display: grid; + } + + .header-command-group { + top: 104px; + right: 10px; + width: 210px; + padding: 7px; + border-color: var(--border); + border-radius: 8px; + } + + .module-nav, + body.sidebar-collapsed .module-nav { + width: 100%; + height: calc(64px + env(safe-area-inset-bottom)); + min-height: 64px; + inset: auto 0 0; + grid-template-columns: repeat(5, minmax(0, 1fr)); + padding: 4px 4px max(4px, env(safe-area-inset-bottom)); + border-top-color: var(--border); + background: rgba(255, 255, 255, .98); + box-shadow: 0 -6px 20px rgba(22, 34, 46, .08); + backdrop-filter: saturate(150%) blur(10px); + } + + .module-nav .module-tab, + body.sidebar-collapsed .module-nav .module-tab { + min-height: 56px; + gap: 3px; + border-radius: 6px; + font-size: 10px; + } + + .module-nav .module-tab .lucide { + width: 20px; + height: 20px; + } + + .module-nav .module-tab.active::before, + .module-nav .module-tab.mobile-active::before { + display: none; + } + + .module-nav .module-tab.active, + .module-nav .module-tab.mobile-active { + background: #eef5fc; + color: var(--action); + } + + .app-main { + width: 100%; + min-height: calc(100dvh - 176px); + padding: 10px 8px 20px; + } + + .mobile-market-selector:not([hidden]) { + height: 44px; + margin-bottom: 10px; + border-color: var(--border); + border-radius: 7px; + box-shadow: var(--shadow-soft); + } + + .mobile-market-selector select { + font-size: 13px; + } + + .overview-strip { + min-height: 182px; + grid-template-columns: 1.2fr 1fr 1fr; + grid-template-rows: 64px 64px 54px; + margin-bottom: 10px; + border-radius: 7px; + } + + .sentiment-block, + .metric { + min-height: 0; + padding: 8px 10px; + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + } + + .sentiment-block { + grid-column: 1; + grid-row: 1 / 3; + align-items: flex-start; + justify-content: center; + flex-direction: column; + gap: 8px; + } + + .sentiment-gauge { + width: 44px; + height: 44px; + flex-basis: 44px; + } + + .sentiment-gauge::before { + inset: 5px; + } + + .sentiment-gauge span { + font-size: 15px; + } + + .sentiment-text { + margin-top: 2px; + font-size: 14px; + } + + .overview-strip .metric:nth-of-type(1) { grid-column: 2; grid-row: 1; } + .overview-strip .metric:nth-of-type(2) { grid-column: 3; grid-row: 1; border-right: 0; } + .overview-strip .metric:nth-of-type(3) { grid-column: 2; grid-row: 2; } + .overview-strip .metric:nth-of-type(4) { grid-column: 3; grid-row: 2; border-right: 0; } + .overview-strip .metric:nth-of-type(5) { grid-column: 1 / 2; grid-row: 3; border-bottom: 0; } + .overview-strip .metric:nth-of-type(6) { grid-column: 2 / 4; grid-row: 3; border-right: 0; border-bottom: 0; } + + .metric { + gap: 2px; + } + + .metric-label { + font-size: 10.5px; + } + + .metric-value { + font-size: 16px; + } + + .metric-value.small { + font-size: 12px; + } + + .workspace-view, + body[data-active-view="screenerView"] .workspace-view, + body[data-active-view="mentorView"] .workspace-view, + body[data-active-view="heavenView"] .workspace-view, + body[data-active-view="reviewWorkspaceView"] .workspace-view { + margin-top: 0; + border-radius: 7px; + } + + .section-toolbar { + min-height: 58px; + align-items: flex-start; + padding: 11px 13px; + } + + .section-title-group { + align-items: flex-start; + flex-wrap: wrap; + gap: 4px 8px; + } + + .section-toolbar h2, + .section-title-group h2 { + font-size: 18px; + } + + .section-subtitle { + width: 100%; + font-size: 11px; + } + + .toolbar-controls { + width: 100%; + align-items: stretch; + flex-wrap: wrap; + } + + .button, + .icon-button { + min-height: 42px; + } + + .search-field input, + .form-field input, + .form-field select { + min-height: 44px; + font-size: 16px; + } + + .segmented { + min-height: 42px; + } + + .segment { + min-height: 40px; + padding: 0 10px; + } + + .data-table { + font-size: 12.5px; + } + + .data-table th, + .data-table td { + height: 44px; + padding: 8px 10px; + } + + .data-table th { + font-size: 11.5px; + } + + .main-grid { + display: block; + min-height: 0; + } + + .main-grid > .table-frame { + min-height: 460px; + border-right: 0; + } + + .insight-rail { + display: grid; + grid-template-columns: 1fr; + border-top: 1px solid var(--border); + } + + .phase-table-frame { + min-height: 480px; + } + + .performance-cards { + grid-template-columns: 1fr 1fr; + } + + .performance-card { + min-height: 118px; + padding: 13px; + } + + .sentiment-cycle-summary { + grid-template-columns: 1fr 1fr; + } + + .sentiment-cycle-current, + .sentiment-cycle-state { + min-height: 98px; + padding: 13px; + } + + .sentiment-cycle-current { + grid-column: 1 / -1; + } + + .sentiment-cycle-state:last-child { + grid-column: 1 / -1; + } + + .sentiment-cycle-analysis { + grid-template-columns: 1fr; + } + + .sentiment-trend-panel { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .sentiment-chart-shell { + height: 260px; + } + + .ladder-board { + padding: 12px 8px 14px; + } + + .rotation-history-heading { + padding: 10px 12px; + } + + .market-feature-summary { + grid-template-columns: 1fr 1fr; + } + + .market-feature-summary > div { + min-height: 62px; + } + + .market-feature-filterbar, + .dragon-filterbar { + align-items: stretch; + flex-direction: column; + padding: 9px 12px; + } + + .market-feature-filterbar .search-field, + .market-feature-filterbar .search-field input, + .dragon-search-field, + .dragon-search-field input { + width: 100%; + } + + .auction-page-header { + padding: 11px 12px; + } + + .auction-workspace-layout { + display: block; + margin: 0; + border-radius: 0; + } + + .auction-dataset-bar { + padding: 0 10px; + } + + .auction-dataset-segments { + min-width: 430px; + } + + .auction-expectation-filterbar { + padding: 9px 10px; + } + + .theme-library-layout { + display: block; + min-height: 0; + } + + .theme-directory-panel { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .theme-directory { + max-height: 300px; + } + + .theme-chart-shell { + height: 250px; + } + + .screener-mode-tabs { + min-height: 50px; + padding: 0 4px; + } + + .screener-mode-tabs button { + min-width: 0; + min-height: 48px; + flex: 1; + padding: 0 4px; + font-size: 12px; + } + + #screenerView .screener-strategy-view { + padding: 8px; + } + + .curated-screener-panel, + .quant-screener-panel { + height: auto; + min-height: 0; + margin: 8px; + } + + .curated-library-pane { + display: block; + overflow: visible; + } + + .curated-strategy-list { + max-height: 380px; + overflow-y: auto; + } + + .curated-strategy-list { + grid-template-columns: 1fr; + } + + .quant-universe-grid { + grid-template-columns: 1fr 1fr; + } + + .mentor-layout { + height: calc(100dvh - 258px - env(safe-area-inset-bottom)); + min-height: 510px; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 56px minmax(0, 1fr); + overflow: hidden; + } + + .mentor-chat-panel { + min-height: 0; + height: 100%; + } + + #mentorView > .section-toolbar { + min-height: 66px; + align-items: center; + flex-direction: row; + } + + #mentorView > .section-toolbar .section-title-group { + flex: 1; + } + + .mentor-messages { + padding: 14px 12px; + } + + .mentor-empty-state { + min-height: 100%; + padding: 10px; + } + + .mentor-empty-state strong { + font-size: 16px; + } + + .mentor-empty-state p { + display: none; + } + + .review-workspace { + display: block; + } + + .workspace-section { + min-width: 0; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .watchlist-section .workspace-table-frame { + min-height: 0; + max-height: 360px; + } + + .watchlist-section .empty-state { + min-height: 160px; + } + + .workspace-heading { + min-height: 52px; + padding: 9px 13px; + } + + .journal-form { + padding: 13px; + } + + #heavenView .heaven-toolbar { + min-height: 62px; + padding: 10px 14px; + } + + #heavenView .heaven-tabs { + min-height: 52px; + padding: 0 14px; + } + + #heavenView .heaven-proverb { + padding: 9px 14px; + } + + #heavenView .heaven-panel { + padding-right: 14px; + padding-left: 14px; + } + + #heavenView .heaven-controls { + align-items: stretch; + flex-direction: column; + min-height: 0; + padding: 14px 0; + } + + .heaven-trend-actions { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + } + + .heaven-trend-empty { + min-height: 330px; + } + + .assistant-dialog, + .settings-dialog, + .stock-dialog, + .account-settings-dialog, + .heaven-reading-dialog { + width: calc(100vw - 16px); + max-width: none; + max-height: calc(100dvh - 20px); + border-radius: 8px; + } +} + +@media (prefers-reduced-motion: reduce) { + .workspace-view.active-view.view-entering, + .module-tab, + .button, + .icon-button { + animation: none; + transition: none; + } +} diff --git a/strategy_tracking.py b/strategy_tracking.py index 7c79109..8be8b99 100644 --- a/strategy_tracking.py +++ b/strategy_tracking.py @@ -21,6 +21,34 @@ class StrategyTrackingService: user_id, run_id, selection_date, strategy_name, candidates ) + def add_candidate(self, user_id: int, run_id: int, code: str) -> dict[str, Any]: + run = self.database.get_screener_run(user_id, run_id) + if not run: + raise ValueError("选股结果不存在或不属于当前账号。") + normalized_code = str(code or "").strip().split(".")[0] + candidate = next( + ( + item for item in run.get("candidates", []) + if str(item.get("code") or item.get("ts_code") or "").split(".")[0] + == normalized_code + ), + None, + ) + if not candidate: + raise ValueError("该股票不在本次选股结果中。") + added = self.record_run( + user_id, + run_id, + str(run.get("meta", {}).get("trade_date") or ""), + str(run.get("strategy_name") or "未命名策略"), + [candidate], + ) + return {"added": added, "tracking": self.list_tracking(user_id)} + + def remove_candidate(self, user_id: int, track_id: int) -> dict[str, Any]: + deleted = self.database.delete_strategy_track(user_id, track_id) + return {"deleted": deleted, "tracking": self.list_tracking(user_id)} + def list_tracking(self, user_id: int, limit_batches: int = 12) -> dict[str, Any]: tracks = self.database.list_strategy_tracks(user_id, limit_batches) if not tracks: diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index 35d7318..8543aa4 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -106,7 +106,29 @@ function mentorDirectory(role = "admin") { return mentors; } -async function mockApplication(page, authSession = session()) { +async function mockApplication(page, authSession = session(), options = {}) { + let screenerTracking = { + batches: [{ + run_id: 44, + selection_date: "20260721", + strategy_name: "Repair confirmation", + items: [{ + id: 9, + code: "002141", + name: "Test Stock", + entry_price: 10, + t1_open: 1.2, + t1_close: 2.1, + t3_close: null, + t5_close: null, + max_gain: 3.4, + max_drawdown: -1.1, + observed_days: 1, + status: "tracking", + }], + }], + summary: { total: 1, observed: 1, t1_win_rate: 100, t5_win_rate: null, average_t5: null }, + }; await page.route("**/api/**", async (route) => { const url = new URL(route.request().url()); let payload = { ok: true }; @@ -159,8 +181,18 @@ async function mockApplication(page, authSession = session()) { { date: "2026-07-24", time: "09:32", open: 10.22, high: 10.23, low: 10.16, close: 10.18, volume: 90, average: 10.18 }, ], }; - } else if (url.pathname === "/api/watchlist" || url.pathname === "/api/notes") payload = { items: [] }; - else if (url.pathname === "/api/alerts") { + } else if (url.pathname === "/api/watchlist") { + payload = { items: [{ + code: "000002", name: "Watch Stock", sector: "Bank", color: "red", + change: 1.86, return_5d: 8.92, attention_score: 72.4, + remark: "观察承接,不追高", market_date: "20260722", + }] }; + } else if (url.pathname === "/api/notes") { + payload = { items: [{ + id: 12, code: "", stock_name: "", trade_date: "20260722", + summary: "缩量修复,主线仍待确认", content: "做对了等待确认。", plan: "只做有承接的核心。", + }] }; + } else if (url.pathname === "/api/alerts") { payload = { items: [{ id: 11, @@ -200,7 +232,7 @@ async function mockApplication(page, authSession = session()) { payload = { items: [{ role: "assistant", content: "Review evidence before forming a conclusion.", context_date: "20260722" }], }; - } else if (url.pathname === "/api/search") payload = { groups: { stocks: [], sectors: [], themes: [], indices: [] } }; + } else if (url.pathname === "/api/search") payload = { groups: { stocks: [{ id: "002141", code: "002141", name: "Test Stock", type: "stock", industry: "Test Sector" }], sectors: [], themes: [], indices: [] } }; else if (url.pathname === "/api/dragon-tiger") { payload = { meta: { trade_date: "2026-07-22", requested_date: "2026-07-22", status: "empty", source: "tushare" }, @@ -261,35 +293,94 @@ async function mockApplication(page, authSession = session()) { payload = { meta: { trade_date: "2026-07-22", carried_forward: false }, summary: { ths_count: 1, dc_count: 1, dual_count: 1 }, combined: [hot], ths: [{ ...hot, rank: 1 }], dc: [{ ...hot, rank: 2 }] }; } else if (url.pathname === "/api/screener/setup") { + options.screenerSetupRequests = (options.screenerSetupRequests || 0) + 1; + const factorFields = [ + ["amount_billion", "成交额(亿元)"], ["above_ma20", "站上20日线"], + ["relative_strength", "相对强度"], ["sector_strength", "板块强度"], + ["volume_ratio_5d", "5日量比"], ["volatility_10d", "10日波动率"], + ["dividend_yield_ttm", "股息率TTM"], + ].map(([id, label]) => ({ id, label })); payload = { trade_date: "20260722", regime: { id: "repair", label: "修复", confidence: 70, reason: "测试", evidence: [] }, regimes: [{ id: "repair", label: "修复" }], - factor_data: { ready: false, date_count: 0 }, + factor_fields: factorFields, + factor_groups: [{ name: "行情与质量", fields: factorFields }], + operators: [">", ">=", "<", "<=", "==", "between"], + factor_data: { + ready: true, + date_count: 45, + start_date: "20260518", + end_date: "20260722", + health: { market: true, auction: true, valuation: true, fundamental: true, dividend_history: true }, + }, llm: { configured: true }, - strategies: [], + strategies: [ + { + id: 1, name: "修复确认", description: "保留原有智能策略流程", regimes: ["repair"], builtin: true, + data_ready: true, missing_data: [], + formula: { + meta: { library: "smart" }, universe: { exclude_st: true, listed_days_min: 120 }, filters: [], + score: [{ field: "relative_strength", weight: 1, direction: "desc" }], limit: 15, min_score: 0.5, + }, + }, + { + id: 2, name: "连续分红质量", description: "持续分红、估值与流动性共同约束。", regimes: ["repair"], builtin: true, + data_ready: true, missing_data: [], + formula: { + meta: { library: "curated", category: "红利价值", quality: "A", frequency: "月度", risk: "中低", data_group: "估值与财务" }, + universe: { exclude_st: true, listed_days_min: 720 }, + filters: [{ field: "dividend_yield_ttm", op: ">=", value: 2 }], + score: [{ field: "dividend_yield_ttm", weight: 1, direction: "desc" }], limit: 20, min_score: 0.5, + }, + }, + ], + }; + if (options.latestScreenerResults) { + payload.latest_results = options.latestScreenerResults; + payload.latest_result = options.latestScreenerResults.smart || null; + } + } else if (url.pathname === "/api/screener/run") { + const body = route.request().postDataJSON(); + options.screenerRunBodies = [...(options.screenerRunBodies || []), body]; + payload = { + result: options.latestScreenerResults?.[body.mode] || { + meta: { + run_id: 99, + trade_date: "20260722", + regime: body.regime, + strategy_name: body.strategy_name, + mode: body.mode, + }, + candidates: [], + disclaimer: "历史统计不代表未来收益", + backtest: null, + }, }; } else if (url.pathname === "/api/screener/tracking") { - payload = { - batches: [{ - selection_date: "20260721", - strategy_name: "Repair confirmation", - items: [{ - code: "002141", - name: "Test Stock", - entry_price: 10, - t1_open: 1.2, - t1_close: 2.1, - t3_close: null, - t5_close: null, - max_gain: 3.4, - max_drawdown: -1.1, - observed_days: 1, - status: "tracking", + if (route.request().method() === "POST") { + const body = route.request().postDataJSON(); + screenerTracking = { + batches: [...screenerTracking.batches, { + run_id: body.run_id, + selection_date: "20260722", + strategy_name: "Manual strategy", + items: [{ id: 10, code: body.code, name: "Manual Stock", entry_price: 12, observed_days: 0, status: "等待 T+1" }], }], - }], - summary: { total: 1, observed: 1, t1_win_rate: 100, t5_win_rate: null, average_t5: null }, + summary: { ...screenerTracking.summary, total: screenerTracking.summary.total + 1 }, + }; + payload = { ok: true, tracking: screenerTracking }; + } else payload = screenerTracking; + } else if (/^\/api\/screener\/tracking\/\d+$/.test(url.pathname)) { + const trackId = Number(url.pathname.split("/").at(-1)); + screenerTracking = { + batches: screenerTracking.batches.map((batch) => ({ + ...batch, + items: batch.items.filter((item) => item.id !== trackId), + })).filter((batch) => batch.items.length), + summary: { ...screenerTracking.summary, total: Math.max(0, screenerTracking.summary.total - 1) }, }; + payload = { ok: true, deleted: true, tracking: screenerTracking }; } else if (url.pathname === "/api/heaven/readings") { payload = { mode: url.searchParams.get("mode") || "fortune", @@ -347,6 +438,7 @@ test("admin shell opens every primary workspace and global search", async ({ pag for (const view of views) { await page.locator(`[data-view="${view}"]`).first().click(); await expect(page.locator(`#${view}`)).toHaveClass(/active-view/); + await expect(page.locator(".module-tab.active")).toHaveCount(1); } await page.keyboard.press("Control+K"); @@ -354,6 +446,537 @@ test("admin shell opens every primary workspace and global search", async ({ pag await expect(page.locator("#globalSearchInput")).toBeFocused(); }); +test("collapsed overview and sentiment decision layout keep a single current reading", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockApplication(page, session("user", true)); + await page.goto("/index.html"); + await expect(page.locator("#sentimentGauge")).toBeHidden(); + await page.evaluate(() => { + state.sentimentHistory = { + available_days: 20, + rows: [{ + trade_date: "2026-07-22", score: 32, label: "情绪偏弱", phase: "退潮", direction: "降温", + day_change: -12, seal_rate: 65, limit_up_count: 32, first_board_count: 20, + second_board_count: 6, three_plus_count: 3, max_height: 4, broken_count: 17, + limit_down_count: 25, previous_limit_count: 40, previous_positive_count: 12, + previous_positive_rate: 30, average_previous_change: -1.2, normalization: "固定锚点", + components: { + breadth: { label: "市场宽度", score: 28, weight: 20, summary: "红盘家数偏少" }, + limit: { label: "涨停生态", score: 42, weight: 25, summary: "封板率仍需确认" }, + profit: { label: "赚钱效应", score: 31, weight: 30, summary: "昨日反馈偏弱" }, + ladder: { label: "连板结构", score: 38, weight: 15, summary: "高度仍在压缩" }, + amount: { label: "成交活跃度", score: 25, weight: 10, summary: "量能低于均值" }, + }, + }], + }; + renderSentimentHistory(); + }); + await page.locator('[data-view="sentimentCycleView"]').first().click(); + + await expect(page.locator("#sentimentStageGuideTitle")).toHaveText("判定口径"); + await expect(page.locator('[data-sentiment-stage]:visible')).toHaveCount(1); + await expect(page.locator('[data-sentiment-stage="退潮"]')).toBeVisible(); + await expect(page.locator("#sentimentPhaseAdvice")).toHaveText("情绪指标继续走弱。"); + const alignment = await page.evaluate(() => { + const guide = document.querySelector(".sentiment-stage-guide").getBoundingClientRect(); + const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect(); + const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect(); + const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect(); + const chart = document.querySelector(".sentiment-chart-shell").getBoundingClientRect(); + const currentGuide = document.querySelector("[data-sentiment-stage].current"); + const detail = document.querySelector(".sentiment-detail-toolbar").getBoundingClientRect(); + const label = document.querySelector(".sentiment-block .metric-label"); + const status = document.querySelector(".sentiment-block .sentiment-text"); + const rangeHeader = document.querySelector(".sentiment-stage-guide-head > span:nth-child(3)").getBoundingClientRect(); + const rangeValue = currentGuide.querySelector(".stage-range").getBoundingClientRect(); + const labelStyle = getComputedStyle(document.querySelector(".sentiment-block .metric-label")); + const statusStyle = getComputedStyle(document.querySelector(".sentiment-block .sentiment-text")); + return { + mainAligned: Math.abs(guide.x - trend.x) < 1 && Math.abs(guide.width - trend.width) < 1 && guide.top > trend.bottom, + railAligned: Math.abs(summary.x - components.x) < 1 && Math.abs(summary.width - components.width) < 1 && components.top > summary.bottom, + detailVisible: detail.top < innerHeight, + chartHeight: chart.height, + guideRowHeight: currentGuide.getBoundingClientRect().height, + guideIsWhite: getComputedStyle(currentGuide).backgroundColor === "rgb(255, 255, 255)", + sameType: labelStyle.fontSize === statusStyle.fontSize + && labelStyle.fontWeight === statusStyle.fontWeight + && labelStyle.lineHeight === statusStyle.lineHeight, + sameBaseline: Math.abs(label.getBoundingClientRect().y - status.getBoundingClientRect().y) < 0.1, + noStatusOffset: statusStyle.marginTop === "0px", + rangeAligned: Math.abs(rangeHeader.right - rangeValue.right) < 1, + }; + }); + expect(alignment.mainAligned).toBe(true); + expect(alignment.railAligned).toBe(true); + expect(alignment.detailVisible).toBe(true); + expect(alignment.chartHeight).toBeGreaterThanOrEqual(340); + expect(alignment.guideRowHeight).toBeLessThanOrEqual(52); + expect(alignment.guideIsWhite).toBe(true); + expect(alignment.sameType).toBe(true); + expect(alignment.sameBaseline).toBe(true); + expect(alignment.noStatusOffset).toBe(true); + expect(alignment.rangeAligned).toBe(true); + + const pageFrames = {}; + for (const [view, headSelector] of [ + ["sentimentCycleView", ".sentiment-cycle-toolbar"], + ["auctionView", ".auction-page-head-v2"], + ["themeLibraryView", ".theme-page-head-v2"], + ["popularityView", ".popularity-page-head-v2"], + ["dragonView", ".dragon-page-head-v2"], + ]) { + await page.locator(`[data-view="${view}"]`).first().click(); + await page.waitForTimeout(350); + pageFrames[view] = await page.evaluate(({ view, headSelector }) => { + const workspace = document.getElementById(view); + const frame = workspace.getBoundingClientRect(); + const head = workspace.querySelector(headSelector).getBoundingClientRect(); + const style = getComputedStyle(workspace); + return { + frame: [Math.round(frame.x), Math.round(frame.y), Math.round(frame.width)], + head: [Math.round(head.x), Math.round(head.y), Math.round(head.width)], + padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft], + background: style.backgroundColor, + border: style.borderTopWidth, + }; + }, { view, headSelector }); + } + const sentimentFrame = JSON.stringify(pageFrames.sentimentCycleView); + for (const view of ["auctionView", "themeLibraryView", "popularityView", "dragonView"]) { + expect(JSON.stringify(pageFrames[view])).toBe(sentimentFrame); + } +}); + +test("limit-up pool separates stock identity and restores the reason column", async ({ page }) => { + await mockApplication(page, session("user", true)); + await page.goto("/index.html"); + await page.evaluate(() => { + state.dashboard.limits = [{ + code: "603221", name: "爱丽家居", streak: 4, change: 9.98, price: 14, + sector: "家居用品", first_time: "09:25:01", last_time: "09:25:01", + open_times: 0, turnover_rate: 0.41, amount_billion: 1.2, + seal_amount_million: 5200, reason: "家居消费方向走强", + }]; + renderLimitTable(); + }); + await page.locator('[data-view="limitPool"]').first().click(); + + expect((await page.locator("#limitTable thead th").allTextContents()).map((text) => text.replace(/[↕▲▼]/g, ""))).toEqual([ + "序号", "股票", "连板", "涨幅(%)", "价格(元)", "所属板块", "首封", "最后封板", "开板(次)", "换手率(%)", "成交额(亿)", "封单额(万)", "涨停原因", + ]); + const cells = page.locator("#limitTableBody tr").first().locator("td"); + await expect(cells).toHaveCount(13); + await expect(cells.nth(1)).toContainText("爱丽家居"); + await expect(cells.nth(1)).toContainText("603221"); + await expect(cells.nth(12)).toHaveText("家居消费方向走强"); + + for (const [view, table, reasonLabel] of [ + ["limitPool", "limitTable", "涨停原因"], + ["brokenView", "brokenTable", "炸板原因"], + ["downView", "downTable", "风险线索"], + ["yesterdayView", "yesterdayTable", "涨停逻辑"], + ]) { + await page.locator(`[data-view="${view}"]`).first().click(); + const geometry = await page.locator(`#${table}`).evaluate((tableNode, reason) => { + const headers = [...tableNode.tHead.rows[0].cells]; + const widths = headers.map((header) => header.getBoundingClientRect().width); + const reasonIndex = headers.findIndex((header) => header.textContent.trim() === reason); + const numeric = headers.map((header, index) => ({ header, index })).filter(({ header }) => header.classList.contains("num")); + return { + index: widths[0], + reason: widths[reasonIndex], + numericMax: Math.max(...numeric.map(({ index }) => widths[index])), + numericAligned: numeric.every(({ header }) => getComputedStyle(header).textAlign === "right"), + numericVariant: numeric.every(({ header }) => getComputedStyle(header).fontVariantNumeric.includes("tabular-nums")), + }; + }, reasonLabel); + expect(geometry.reason).toBeGreaterThan(geometry.numericMax); + expect(geometry.index).toBeLessThan(geometry.numericMax); + expect(geometry.numericAligned).toBe(true); + expect(geometry.numericVariant).toBe(true); + } +}); + +test("broken pool matches the approved table structure and keeps independent interactions", async ({ page }) => { + await mockApplication(page, session("user", true)); + await page.goto("/index.html"); + await page.evaluate(() => { + state.dashboard.broken = [ + { code: "002156", name: "通富微电", change: 9.77, price: 76.64, sector: "半导体", first_time: "10:08:03", open_times: 4, turnover_rate: 17.36, amount_billion: 198.18, reason: "芯片方向冲高回落" }, + { code: "300214", name: "日科化学", change: 15.59, price: 11.86, sector: "化学制品", first_time: "09:54:21", open_times: 1, turnover_rate: 19.02, amount_billion: 10.37, reason: "化工板块异动" }, + { code: "601678", name: "滨化股份", change: 5.14, price: 6.54, sector: "化学原料", first_time: "09:35:55", open_times: 8, turnover_rate: 25.23, amount_billion: 34.64, reason: "高位反复开板" }, + ]; + renderBrokenTable(state.dashboard.broken); + }); + await page.locator('[data-view="brokenView"]').first().click(); + + await expect(page.locator("#brokenView")).toHaveClass(/redesigned-broken-view/); + expect((await page.locator("#brokenTable thead th").allTextContents()).map((text) => text.replace(/[↕▲▼]/g, ""))).toEqual([ + "序号", "股票", "现价涨幅(%)", "距涨停(%)", "价格(元)", "所属板块", "首次触板", "开板(次)", "换手率(%)", "成交额(亿)", "炸板原因", + ]); + await expect(page.locator("#brokenTableBody tr")).toHaveCount(3); + await expect(page.locator("#brokenTableBody tr").nth(0)).toContainText("0.23"); + await expect(page.locator("#brokenTableBody tr").nth(1)).toContainText("4.41"); + await expect(page.locator("#brokenTableBody tr").nth(2)).toContainText("反复炸 ×8"); + await expect(page.locator("#brokenTableBody")).toContainText("芯片方向冲高回落"); + + await page.locator("#brokenSearch").fill("半导体"); + await expect(page.locator("#brokenTableBody tr")).toHaveCount(1); + await expect(page.locator("#brokenTableBody")).toContainText("通富微电"); + await page.locator("#brokenSearch").fill(""); + + await page.locator('[data-broken-sort="amount_billion"]').click(); + await expect(page.locator("#brokenTableBody tr").first()).toContainText("通富微电"); + await page.locator('[data-broken-sort="amount_billion"]').click(); + await expect(page.locator("#brokenTableBody tr").first()).toContainText("日科化学"); + await expect(page.locator("#brokenCount")).toHaveText("3 只"); + await expect(page.locator("#brokenMeta")).toContainText("数据日期 2026-07-22"); + expect(await page.evaluate(() => [ + brokenLimitRate({ code: "600000", name: "普通股票" }), + brokenLimitRate({ code: "300001", name: "创业板股票" }), + brokenLimitRate({ code: "830001", name: "北交所股票" }), + brokenLimitRate({ code: "300001", name: "ST测试" }), + ])).toEqual([10, 20, 30, 10]); +}); + +test("down-limit pool matches the approved risk-cluster table structure", async ({ page }) => { + await mockApplication(page, session("user", true)); + await page.goto("/index.html"); + await page.evaluate(() => { + state.dashboard.down_limits = [ + { code: "000037", name: "深南电A", change: -10.03, price: 8.97, sector: "电力", turnover_rate: 11.78, amount_billion: 3.68, streak: 2, reason: "连续弱势跌停" }, + { code: "000539", name: "粤电力A", change: -10.02, price: 5.66, sector: "电力", turnover_rate: 5.48, amount_billion: 8.16, streak: 1, reason: "板块集中释放风险" }, + { code: "001896", name: "豫能控股", change: -10.01, price: 14.39, sector: "电力", turnover_rate: 9.09, amount_billion: 20.64, streak: 1, reason: "高位补跌" }, + { code: "300045", name: "华力创通", change: -20.03, price: 11.46, sector: "军工电子", turnover_rate: 9.54, amount_billion: 5.76, streak: 1, reason: "放量破位" }, + ]; + renderDownTable(state.dashboard.down_limits); + }); + await page.locator('[data-view="downView"]').first().click(); + + await expect(page.locator("#downView")).toHaveClass(/redesigned-down-view/); + expect((await page.locator("#downTable thead th").allTextContents()).map((text) => text.replace(/[↕▲▼]/g, ""))).toEqual([ + "序号", "股票", "跌幅(%)", "价格(元)", "所属板块", "换手率(%)", "成交额(亿)", "连续跌停(天)", "风险线索", + ]); + await expect(page.locator("#downTableBody tr")).toHaveCount(4); + await expect(page.locator("#downSectorCluster")).toHaveText("电力集中跌停 ×3"); + await expect(page.locator("#downSectorCluster")).toBeVisible(); + await expect(page.locator("#downTableBody tr").first()).toContainText("2"); + await expect(page.locator("#downTableBody")).toContainText("连续弱势跌停"); + + await page.locator("#downSearch").fill("军工电子"); + await expect(page.locator("#downTableBody tr")).toHaveCount(1); + await expect(page.locator("#downTableBody")).toContainText("华力创通"); + await page.locator("#downSearch").fill(""); + + await page.locator('[data-down-sort="change"]').click(); + await expect(page.locator("#downTableBody tr").first()).toContainText("华力创通"); + await page.locator('[data-down-sort="amount_billion"]').click(); + await expect(page.locator("#downTableBody tr").first()).toContainText("深南电A"); + await expect(page.locator("#downCount")).toHaveText("4 只"); + await expect(page.locator("#downMeta")).toContainText("数据日期 2026-07-22"); +}); + +test("yesterday-limit pool exposes outcome summaries and combined filters", async ({ page }) => { + await mockApplication(page, session("user", true)); + await page.goto("/index.html"); + await page.evaluate(() => { + state.dashboard.meta.previous_trade_date = "2026-07-21"; + state.dashboard.yesterday_limits = [ + { code: "000011", name: "深物业A", prior_streak: 1, current_change: 9.99, outcome: "晋级", current_streak: 2, sector: "房地产开发", reason: "地产政策预期" }, + { code: "000533", name: "顺钠股份", prior_streak: 1, current_change: 10.02, outcome: "晋级", current_streak: 2, sector: "电网设备", reason: "电网设备走强" }, + { code: "000017", name: "深中华A", prior_streak: 1, current_change: 2.11, outcome: "断板", current_streak: 0, sector: "饰品", reason: "消费修复" }, + { code: "000035", name: "中国天楹", prior_streak: 1, current_change: -5.38, outcome: "断板", current_streak: 0, sector: "环境治理", reason: "环保题材" }, + { code: "001258", name: "立新能源", prior_streak: 6, current_change: 7.60, outcome: "炸板", current_streak: 0, sector: "电力", reason: "新能源核心" }, + { code: "001388", name: "信通电子", prior_streak: 1, current_change: -9.99, outcome: "跌停", current_streak: 0, sector: "电网设备", reason: "智能电网" }, + ]; + renderYesterdayTable(state.dashboard.yesterday_limits); + }); + await page.locator('[data-view="yesterdayView"]').first().click(); + + await expect(page.locator("#yesterdayView")).toHaveClass(/redesigned-yesterday-view/); + expect((await page.locator("#yesterdayTable thead th").allTextContents()).map((text) => text.replace(/[↕▲▼]/g, ""))).toEqual([ + "序号", "股票", "昨日高度(板)", "今日涨幅(%)", "今日结果", "当前高度(板)", "所属板块", "涨停逻辑", + ]); + await expect(page.locator("#yesterdayAllCount")).toHaveText("6"); + await expect(page.locator("#yesterdayAdvanceCount")).toHaveText("2"); + await expect(page.locator("#yesterdayAdvanceRate")).toHaveText("晋级率 33.3%"); + await expect(page.locator("#yesterdayPositiveCount")).toHaveText("4"); + await expect(page.locator("#yesterdayPositiveRate")).toHaveText("兑现率 66.7%"); + await expect(page.locator("#yesterdayFailCount")).toHaveText("2"); + await expect(page.locator("#yesterdayRiskCount")).toHaveText("2"); + await expect(page.locator("#yesterdayRiskRate")).toHaveText("亏钱效应 33.3%"); + + await page.locator('[data-yesterday-filter="positive"]').click(); + await expect(page.locator("#yesterdayTableBody tr")).toHaveCount(4); + await expect(page.locator("#yesterdayTableBody")).toContainText("深物业A"); + await expect(page.locator("#yesterdayTableBody")).toContainText("立新能源"); + + await page.locator('[data-yesterday-filter="risk"]').click(); + await expect(page.locator("#yesterdayTableBody tr")).toHaveCount(2); + await expect(page.locator("#yesterdayTableBody")).toContainText("立新能源"); + await expect(page.locator("#yesterdayTableBody")).toContainText("信通电子"); + await page.locator("#yesterdaySearch").fill("电网设备"); + await expect(page.locator("#yesterdayTableBody tr")).toHaveCount(1); + await expect(page.locator("#yesterdayTableBody")).toContainText("信通电子"); + await page.locator("#yesterdaySearch").fill(""); + await page.locator('[data-yesterday-filter="all"]').click(); + + await page.locator('[data-yesterday-sort="current_change"]').click(); + await expect(page.locator("#yesterdayTableBody tr").first()).toContainText("顺钠股份"); + await expect(page.locator("#yesterdayTableBody tr").nth(2).locator("td").nth(5)).toBeEmpty(); + await expect(page.locator("#yesterdayTableBody")).toContainText("地产政策预期"); + await expect(page.locator("#yesterdayMeta")).toHaveText(" · 昨日 2026-07-21 → 今日 2026-07-22"); +}); + +test("limit-up performance transfers the approved tier cards and market conclusion", async ({ page }) => { + await mockApplication(page, session("user", true)); + await page.goto("/index.html"); + await page.evaluate(() => { + state.dashboard.meta.previous_trade_date = "2026-07-21"; + state.dashboard.meta.updated_at = "2026-07-22T15:00:00+08:00"; + state.dashboard.overview = { + ...state.dashboard.overview, + up_count: 555, + flat_count: 0, + down_count: 4940, + limit_up_count: 40, + limit_down_count: 25, + sentiment_phase: "退潮", + }; + state.dashboard.limit_performance = [ + { level: 6, label: "昨日6板", count: 1, advanced: 0, advance_rate: 0, positive_rate: 0, average_change: -9.8 }, + { level: 4, label: "昨日4板", count: 1, advanced: 0, advance_rate: 0, positive_rate: 0, average_change: -6.2 }, + { level: 3, label: "昨日3板", count: 4, advanced: 2, advance_rate: 50, positive_rate: 75, average_change: 3.4 }, + { level: 2, label: "昨日2板", count: 9, advanced: 2, advance_rate: 22.2, positive_rate: 44.4, average_change: 0.8 }, + { level: 1, label: "昨日首板", count: 101, advanced: 13, advance_rate: 12.9, positive_rate: 35.6, average_change: -1.2 }, + ]; + renderPerformance(state.dashboard.limit_performance); + }); + await page.locator('[data-view="performanceView"]').first().click(); + + await expect(page.locator("#performanceView")).toHaveClass(/redesigned-performance-view/); + await expect(page.locator("#performanceDateRange")).toHaveText("昨日 2026-07-21 → 今日 2026-07-22"); + await expect(page.locator("#performanceCards .performance-stage-card")).toHaveCount(5); + await expect(page.locator("#performanceCards .performance-stage-card").first()).toContainText("昨日6板 → 今日"); + await expect(page.locator("#performanceCards .performance-stage-card").first()).toContainText("失效"); + await expect(page.locator("#performanceCards .performance-stage-card").nth(2)).toContainText("50.0%"); + await expect(page.locator("#performanceCards .performance-stage-card").nth(2)).toContainText("活跃"); + await expect(page.locator("#performanceCards .performance-stage-card").nth(4)).toContainText("危险"); + await expect(page.locator("#performanceTableBody")).toHaveCount(0); + + await expect(page.locator("#breadthUpCount")).toHaveText("555"); + await expect(page.locator("#breadthDownCount")).toHaveText("4,940"); + await expect(page.locator("#breadthRatio")).toHaveText("10.1%"); + await expect(page.locator("#breadthWarning")).toHaveText("△ 宽度极差,涨跌停 40:25"); + await expect(page.locator("#performanceConclusion")).toContainText("高位晋级率全线失效"); + await expect(page.locator("#performanceConclusion")).toContainText("昨日3板晋级率最高"); + await expect(page.locator("#performanceConclusion")).toContainText("当前情绪周期「退潮」"); +}); + +test("market ladder transfers tier bands, sorting and structural insights", async ({ page }) => { + await mockApplication(page, session("admin", true)); + await page.goto("/index.html"); + await page.evaluate(() => { + state.dashboard.meta.previous_trade_date = "2026-07-21"; + state.dashboard.yesterday_limits = [{ prior_streak: 6 }, { prior_streak: 3 }]; + state.dashboard.limit_performance = [ + { level: 4, label: "昨日3板", advance_rate: 50 }, + { level: 3, label: "昨日2板", advance_rate: 22.2 }, + { level: 2, label: "昨日首板", advance_rate: 12.9 }, + ]; + state.dashboard.ladders = [ + { level: 4, label: "4板", count: 2, stocks: [ + { code: "002879", name: "长缆科技", sector: "电网设备", first_time: "09:25:00", open_times: 2, seal_amount_million: 7247 }, + { code: "603221", name: "爱丽家居", sector: "家居用品", first_time: "09:25:01", open_times: 0, seal_amount_million: 27572 }, + ] }, + { level: 3, label: "3板", count: 2, stocks: [ + { code: "000595", name: "新能股份", sector: "电力", first_time: "09:25:00", open_times: 14, seal_amount_million: 1239 }, + { code: "301234", name: "五洲医疗", sector: "医疗器械", first_time: "09:25:00", open_times: 0, seal_amount_million: 39679 }, + ] }, + { level: 2, label: "2板", count: 9, stocks: Array.from({ length: 9 }, (_, index) => ({ + code: `000${String(index + 1).padStart(3, "0")}`, name: `二板股票${index + 1}`, sector: "电网设备", first_time: `09:3${index}:00`, open_times: index, amount_billion: 1.2, + })) }, + { level: 1, label: "首板", count: 3, stocks: [ + { code: "002374", name: "中锐股份", sector: "包装印刷", first_time: "09:31:33", open_times: 0, seal_amount_million: 5210 }, + { code: "300414", name: "中光防雷", sector: "通信设备", first_time: "09:34:42", open_times: 1, seal_amount_million: 3365 }, + { code: "002012", name: "凯恩股份", sector: "造纸", first_time: "09:35:45", open_times: 0, seal_amount_million: 2874 }, + ] }, + ]; + renderLadderBoard(state.dashboard.ladders); + }); + await page.locator('[data-view="ladderView"]').first().click(); + + await expect(page.locator("#ladderView")).toHaveClass(/redesigned-ladder-view/); + await expect(page.locator("#ladderDateRange")).toHaveText("数据日期 2026-07-22"); + await expect(page.locator("#ladderBoard .market-ladder-tier")).toHaveCount(5); + await expect(page.locator("#ladderBoard .market-ladder-tier").first()).toContainText("5板+"); + await expect(page.locator("#ladderBoard .market-ladder-tier").first()).toContainText("断层"); + await expect(page.locator("#ladderBoard .market-ladder-stock")).toHaveCount(15); + await expect(page.locator("#ladderBoard .market-ladder-tag.one-price")).toHaveCount(2); + const equalCardWidths = await page.locator("#ladderBoard .market-ladder-stock").evaluateAll((cards) => cards.map((card) => card.getBoundingClientRect().width)); + expect(Math.max(...equalCardWidths) - Math.min(...equalCardWidths)).toBeLessThan(1); + await expect(page.locator("#ladderInsights .market-ladder-apex-card")).toContainText("4 板"); + await expect(page.locator("#ladderInsights")).toContainText("较昨日 6 板 ↓ 空间压缩"); + await expect(page.locator("#ladderInsights .market-ladder-rate-list")).toContainText("50.0%"); + + await page.locator('[data-ladder-sort="open"]').click(); + await expect(page.locator('[data-ladder-sort="open"]')).toHaveClass(/active/); + await expect(page.locator("#ladderBoard .market-ladder-tier").nth(2).locator(".market-ladder-stock").first()).toContainText("五洲医疗"); + await page.locator('[data-ladder-level="2"]').click(); + await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-stock")).toHaveCount(9); + await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-more")).toContainText("收起"); + await page.locator('[data-ladder-level="2"]').click(); + await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-stock")).toHaveCount(8); + await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-more")).toContainText("展开剩余 1 只"); +}); + +test("sector rotation transfers the nine-day matrix, tracking and sortable detail", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockApplication(page, session("admin", true)); + await page.goto("/index.html"); + await page.evaluate(() => { + const dates = ["20260714", "20260715", "20260716", "20260717", "20260720", "20260721", "20260722", "20260723", "20260724"]; + state.dashboard.meta.trade_date = "20260724"; + state.rotationHistory = { + rows: dates.map((tradeDate, dayIndex) => ({ + trade_date: tradeDate, + sectors: [ + { name: "电网设备", rank: 1, count: 5 + dayIndex, strength: 92 }, + { name: "计算机设备与自动化设备", rank: 2, count: 3, strength: 76 }, + { name: `轮动板块${dayIndex + 1}`, rank: 3, count: 1, strength: 58 }, + ], + })), + }; + state.rotationHistoryKey = `${document.querySelector("#tradeDate").value}:9`; + state.dashboard.sector_rotation = [ + { rank: 1, name: "电网设备", count: 8, previous_count: 3, delta: 5, strength: 96, max_streak: 4, leader: "长缆科技", amount_billion: 19.6 }, + { rank: 2, name: "半导体", count: 3, previous_count: 0, delta: 3, strength: 78, max_streak: 2, leader: "测试股份", amount_billion: 30.0 }, + { rank: 3, name: "电力", count: 1, previous_count: 8, delta: -7, strength: 67, max_streak: 3, leader: "新能股份", amount_billion: 7.3 }, + ]; + state.dashboard.sectors = [ + { name: "电网设备", change: 4.8 }, + { name: "半导体", change: 2.1 }, + { name: "电力", change: -1.6 }, + ]; + renderRotationHistory(); + renderRotationTable(state.dashboard.sector_rotation, state.dashboard.sectors); + }); + await page.locator('[data-view="rotationView"]').first().click(); + + await expect(page.locator("#rotationView")).toHaveClass(/redesigned-rotation-view/); + await expect(page.locator("#rotationHistoryRange")).toContainText("2026-07-14 → 2026-07-24"); + await expect(page.locator("#rotationHistory .rotation-day")).toHaveCount(9); + await expect(page.locator("#rotationHistory .rotation-day").first()).toContainText("07-14"); + await expect(page.locator("#rotationHistory .rotation-day").last()).toContainText("07-24"); + await expect(page.locator("#rotationHistory .rotation-day").last()).toHaveClass(/latest-day/); + await expect(page.locator("#rotationView .rotation-legend")).not.toContainText("单元格 ="); + const firstDayCells = page.locator("#rotationHistory .rotation-day").first().locator(".rotation-sector-chip"); + await expect(firstDayCells.nth(0)).toHaveClass(/heat-strong/); + await expect(firstDayCells.nth(1)).toHaveClass(/heat-warm/); + await expect(firstDayCells.nth(2)).toHaveClass(/heat-mild/); + const cellVisuals = await firstDayCells.evaluateAll((cells) => cells.map((cell) => { + const style = getComputedStyle(cell); + return { background: style.backgroundColor, radius: parseFloat(style.borderRadius), duration: style.transitionDuration }; + })); + expect(new Set(cellVisuals.map((item) => item.background)).size).toBe(3); + expect(cellVisuals.every((item) => item.radius >= 7)).toBe(true); + expect(cellVisuals.every((item) => item.duration.includes("0.28s"))).toBe(true); + await firstDayCells.nth(1).hover(); + await page.waitForTimeout(300); + expect(await firstDayCells.nth(1).evaluate((cell) => getComputedStyle(cell).transform)).not.toBe("none"); + const matrixFits = await page.locator("#rotationHistory").evaluate((element) => element.scrollWidth <= element.clientWidth + 1); + expect(matrixFits).toBe(true); + const longNameWrapsWithoutClipping = await page.locator("#rotationHistory .rotation-sector-chip strong", { hasText: "计算机设备与自动化设备" }).first().evaluate((element) => ({ + horizontal: element.scrollWidth <= element.clientWidth + 1, + vertical: element.scrollHeight <= element.clientHeight + 1, + })); + expect(longNameWrapsWithoutClipping).toEqual({ horizontal: true, vertical: true }); + + await page.locator('[data-rotation-sector="电网设备"]').first().click(); + await expect(page.locator("#rotationTracker")).toBeVisible(); + await expect(page.locator("#rotationTracker")).toContainText("近 9 日在榜 9 天"); + await expect(page.locator("#rotationTableBody tr").first()).toHaveClass(/selected/); + await page.locator("#rotationTracker .rotation-track-cancel").click(); + await expect(page.locator("#rotationTracker")).toBeHidden(); + + await expect(page.locator("#rotationTableBody .trend-new")).toContainText("新进"); + await page.locator('#rotationTable th[title^="变化"]').click(); + await expect(page.locator("#rotationTableBody tr").first()).toContainText("-7"); + await page.locator('[data-rotation-order="latest"]').click(); + await expect(page.locator("#rotationHistory .rotation-day").first()).toContainText("07-24"); + await expect(page.locator("#rotationHistoryRange")).toContainText("由近到远,左侧为最新交易日"); +}); + +test("collection auction transfers the prototype hierarchy and keeps every dataset workflow", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockApplication(page, session("admin", true)); + await page.goto("/index.html"); + await page.locator('[data-view="auctionView"]').first().click(); + await expect(page.locator("#auctionPhaseTitle")).toHaveText("今日竞价已定格"); + await page.evaluate(() => { + const base = state.auctionData.focus_rows[0]; + state.auctionData.focus_rows = [ + { ...base, code: "002141", name: "高分标的", attention_score: 92, change: 6.2, amount_million: 30, volume_ratio: 4.8 }, + { ...base, code: "000001", name: "低分标的", attention_score: 68, change: -1.5, amount_million: 8, volume_ratio: 1.2, expectation: "低于预期", core_tags: [] }, + ]; + state.auctionData.summary = { ...state.auctionData.summary, focus_count: 2, candidate_count: 2, stock_count: 5470, one_price_count: 1, amount_billion: 202.8 }; + state.auctionData.themes.carry = [ + { name: "新型电力", status: "强承接", prior_limit_count: 5, leader: "立新能源", median_change: 2.36 }, + { name: "专用机械", status: "有承接", prior_limit_count: 6, leader: "长城军工", median_change: 1.82 }, + { name: "化工原料", status: "有承接", prior_limit_count: 8, leader: "金牛化工", median_change: 1.54 }, + { name: "电气设备", status: "承接弱", prior_limit_count: 33, leader: "长缆科技", median_change: 0.88 }, + { name: "运输设备", status: "分歧", prior_limit_count: 5, leader: "北自科技", median_change: 0 }, + ]; + state.auctionData.amount_history = Array.from({ length: 10 }, (_, index) => ({ + trade_date: `2026-07-${String(index + 13).padStart(2, "0")}`, + amount_billion: 150 + index * 6, + stock_count: 5000, + })); + state.auctionSortKey = "attention_score"; + state.auctionSortDirection = "desc"; + renderAuctionCenter(); + }); + + await expect(page.locator("#auctionView")).toHaveClass(/redesigned-auction-view/); + await expect(page.locator("#auctionSummary > div")).toHaveCount(4); + await expect(page.locator("#auctionSummary")).toContainText("5,470 只"); + const auctionTabGeometry = await page.locator(".auction-tabs-v2").evaluate((tabs) => { + const row = tabs.getBoundingClientRect(); + const summary = tabs.querySelector("#auctionSummary").getBoundingClientRect(); + return { rowRight: row.right, summaryRight: summary.right }; + }); + expect(Math.abs(auctionTabGeometry.rowRight - auctionTabGeometry.summaryRight)).toBeLessThanOrEqual(1); + const datasetLabels = await page.locator("[data-auction-dataset]").allTextContents(); + expect(datasetLabels.map((label) => label.replace(/\s+/g, " ").trim())).toEqual(["重点异动 2", "我的自选 1", "全部候选 2", "竞价一字 1"]); + const workspaceColumns = await page.locator(".auction-workspace-v2").evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").filter(Boolean).length); + expect(workspaceColumns).toBe(2); + await expect(page.locator(".auction-side-v2 .auction-side-card")).toHaveCount(2); + await expect(page.locator(".auction-news-entry")).toHaveCount(0); + await expect(page.locator("#auctionThemeCarry .auction-theme-row")).toHaveCount(5); + await expect(page.locator("#auctionAmountTrend .auction-amount-day")).toHaveCount(10); + + await expect(page.locator("#auctionTableHead th")).toHaveCount(8); + await expect(page.locator("#auctionTableHead th[data-auction-sort]")).toHaveCount(4); + await expect(page.locator("#auctionTableBody tr").first()).toContainText("高分标的"); + await page.locator('#auctionTableHead th[data-auction-sort="attention_score"]').click(); + await expect(page.locator("#auctionTableBody tr").first()).toContainText("低分标的"); + await page.locator('[data-auction-dataset="onePrice"]').click(); + await expect(page.locator("#auctionTableHead th")).toHaveCount(6); + await expect(page.locator("#auctionExpectationControls")).toBeHidden(); + await expect(page.locator("#auctionSearch")).toBeVisible(); + await expect(page.locator("#auctionExportButton")).toBeVisible(); + const download = page.waitForEvent("download"); + await page.locator("#auctionExportButton").click(); + await download; + await page.locator("#auctionTableBody tr").first().click(); + await expect(page.locator("#stockDialog")).toBeVisible(); + await page.locator("#closeStockDialog").click(); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.locator(".auction-summary-v2 > div")).toHaveCount(4); + await expect(page.locator(".auction-side-v2 .auction-side-card")).toHaveCount(2); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true); +}); + test("auction, themes and popularity reuse stock detail interactions", async ({ page }) => { await mockApplication(page, session("user", true)); await page.goto("/index.html"); @@ -365,15 +988,16 @@ test("auction, themes and popularity reuse stock detail interactions", async ({ await expect(page.locator("#auctionTableBody")).toContainText("人气前5"); await page.locator('[data-auction-filter="above"]').click(); await expect(page.locator("#auctionTableBody tr")).toHaveCount(1); - await expect(page.locator("#auctionAboveCount")).toHaveText("1"); + await expect(page.locator('[data-auction-filter="above"]')).toHaveClass(/active/); await expect(page.locator("#auctionThemeCarry")).toContainText("强承接"); await expect(page.locator("#auctionAmountValue")).toHaveText("2.50 亿"); - await expect(page.locator("#auctionNewsTitle")).toHaveText("隔夜消息反馈"); - await expect(page.locator(".auction-news-entry")).toContainText("暂不可用"); + await expect(page.locator("#auctionNewsTitle")).toHaveCount(0); + await expect(page.locator(".auction-news-entry")).toHaveCount(0); await page.locator('[data-auction-dataset="onePrice"]').click(); await expect(page.locator("#auctionTableBody tr")).toHaveCount(1); await expect(page.locator("#auctionTableBody")).toContainText("三板以上"); - await expect(page.locator("#auctionExpectationFilterbar")).toBeHidden(); + await expect(page.locator("#auctionExpectationControls")).toBeHidden(); + await expect(page.locator("#auctionSearch")).toBeVisible(); await page.locator('[data-auction-dataset="watchlist"]').click(); await expect(page.locator("#auctionTableBody tr")).toHaveCount(1); await expect(page.locator("#auctionTableBody")).toContainText("Watch Stock"); @@ -395,11 +1019,302 @@ test("auction, themes and popularity reuse stock detail interactions", async ({ await page.locator('[data-view="popularityView"]').first().click(); await expect(page.locator("#popularityTableBody tr")).toHaveCount(1); - await expect(page.locator("#popularityTableBody")).toContainText("双榜共识"); + await expect(page.locator("#popularitySummary")).toContainText("双榜共识"); + await expect(page.locator("#popularityTableBody")).not.toContainText("双榜共识"); await page.locator("#popularityTableBody tr").click(); await expect(page.locator("#stockDialog")).toBeVisible(); }); +test("theme library preserves the full master-detail workflow in its redesigned layout", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockApplication(page, session("user", true)); + await page.goto("/index.html"); + await page.locator('[data-view="themeLibraryView"]').first().click(); + + await expect(page.locator("#themeLibraryView")).toHaveClass(/redesigned-theme-view/); + await expect(page.locator(".theme-summary-v2 > div")).toHaveCount(4); + await expect(page.locator(".theme-directory-card-v2")).toBeVisible(); + await expect(page.locator(".theme-market-card-v2")).toBeVisible(); + await expect(page.locator(".theme-members-card-v2")).toBeVisible(); + await expect(page.locator("#themeDetailName")).toHaveText("人工智能"); + await expect(page.locator("#themeDetailMetrics > div")).toHaveCount(5); + await expect(page.locator("#themeMemberCount")).toHaveText("有行情 1 / 1"); + await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveAttribute("aria-pressed", "true"); + expect(await page.locator("#themeDetailChart").evaluate((canvas) => canvas.toDataURL().length)).toBeGreaterThan(100); + + await page.locator("#themeSearch").fill("不存在的题材"); + await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveCount(0); + await expect(page.locator("#themeDirectory")).toContainText("没有匹配的题材"); + await page.locator("#themeSearch").fill("人工智能"); + await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveCount(1); + + await page.locator("#themeMemberTableBody tr").click(); + await expect(page.locator("#stockDialog")).toBeVisible(); + await page.locator("#closeStockDialog").click(); + + await page.evaluate(() => { + const directory = document.querySelector("#themeDirectory"); + const directorySeed = directory.querySelector("button"); + const members = document.querySelector("#themeMemberTableBody"); + const memberSeed = members.querySelector("tr"); + for (let index = 0; index < 24; index += 1) directory.appendChild(directorySeed.cloneNode(true)); + for (let index = 0; index < 20; index += 1) members.appendChild(memberSeed.cloneNode(true)); + }); + const desktop = await page.evaluate(() => ({ + columns: getComputedStyle(document.querySelector(".theme-library-workspace-v2")).gridTemplateColumns.split(" ").filter(Boolean).length, + mainOverflows: document.querySelector(".app-main").scrollHeight > document.querySelector(".app-main").clientHeight + 1, + directoryOverflows: document.querySelector("#themeDirectory").scrollHeight > document.querySelector("#themeDirectory").clientHeight + 1, + membersOverflow: document.querySelector(".theme-members-frame-v2").scrollHeight > document.querySelector(".theme-members-frame-v2").clientHeight + 1, + })); + expect(desktop).toEqual({ columns: 2, mainOverflows: false, directoryOverflows: true, membersOverflow: true }); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.locator(".theme-head-actions-v2")).toBeVisible(); + const mobile = await page.evaluate(() => ({ + columns: getComputedStyle(document.querySelector(".theme-library-workspace-v2")).gridTemplateColumns.split(" ").filter(Boolean).length, + pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1, + memberTableScrolls: document.querySelector(".theme-members-frame-v2").scrollWidth > document.querySelector(".theme-members-frame-v2").clientWidth + 1, + })); + expect(mobile).toEqual({ columns: 1, pageFits: true, memberTableScrolls: true }); +}); + +test("popularity ranking transfers the three-glance hierarchy and dynamic source tables", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockApplication(page, session("user", true)); + await page.goto("/index.html"); + await page.locator('[data-view="popularityView"]').first().click(); + + await expect(page.locator("#popularityView")).toHaveClass(/redesigned-popularity-view/); + await expect(page.locator("#popularitySummary article")).toHaveCount(3); + await expect(page.locator("#popularitySummary article").nth(0)).toContainText("同花顺热度 Top3"); + await expect(page.locator("#popularitySummary article").nth(1)).toContainText("东方财富热度 Top3"); + await expect(page.locator("#popularitySummary article").nth(2)).toContainText("双榜共识"); + const glanceStyles = await page.locator("#popularitySummary").evaluate((summary) => ({ + background: getComputedStyle(summary).backgroundColor, + shadow: getComputedStyle(summary).boxShadow, + borderWidth: getComputedStyle(summary).borderWidth, + cardBackgrounds: [...summary.children].map((card) => getComputedStyle(card).backgroundColor), + cardShadows: [...summary.children].map((card) => getComputedStyle(card).boxShadow), + })); + expect(new Set(glanceStyles.cardBackgrounds).size).toBe(1); + expect(glanceStyles.cardBackgrounds[0]).not.toBe(glanceStyles.background); + expect(glanceStyles.shadow).toBe("none"); + expect(glanceStyles.borderWidth).toBe("0px"); + expect(glanceStyles.cardShadows).toEqual(["none", "none", "none"]); + await expect(page.locator("#popularityTableTitle")).toHaveText("双榜综合榜"); + await expect(page.locator("#popularityTableHead th")).toHaveCount(8); + await expect(page.locator("#popularityTableHead")).not.toContainText("榜单状态"); + await expect(page.locator("#popularityTableBody .popularity-source-tag-v2")).toHaveCount(0); + + await page.locator('[data-popularity-source="ths"]').click(); + await expect(page.locator("#popularityTableTitle")).toHaveText("同花顺榜"); + await expect(page.locator("#popularityTableHead")).toContainText("榜单状态"); + await expect(page.locator("#popularityTableHead")).not.toContainText("东方财富"); + await expect(page.locator("#popularityTableBody .popularity-source-tag-v2")).toHaveText("双榜共识"); + + await page.locator('[data-popularity-source="dc"]').click(); + await expect(page.locator("#popularityTableTitle")).toHaveText("东方财富榜"); + await expect(page.locator("#popularityTableHead")).not.toContainText("同花顺"); + await page.locator("#popularitySearch").fill("不存在的股票"); + await expect(page.locator("#popularityEmpty")).toBeVisible(); + await page.locator("#popularitySearch").fill("Test Stock"); + await expect(page.locator("#popularityTableBody tr")).toHaveCount(1); + + await page.locator("#popularityTableBody tr").click(); + await expect(page.locator("#stockDialog")).toBeVisible(); + await page.locator("#closeStockDialog").click(); + + await page.locator('[data-popularity-source="combined"]').click(); + await page.evaluate(() => { + const body = document.querySelector("#popularityTableBody"); + const seed = body.querySelector("tr"); + for (let index = 0; index < 30; index += 1) body.appendChild(seed.cloneNode(true)); + }); + const desktop = await page.evaluate(() => ({ + mainOverflows: document.querySelector(".app-main").scrollHeight > document.querySelector(".app-main").clientHeight + 1, + tableOverflows: document.querySelector(".popularity-table-frame-v2").scrollHeight > document.querySelector(".popularity-table-frame-v2").clientHeight + 1, + })); + expect(desktop).toEqual({ mainOverflows: false, tableOverflows: true }); + + await page.setViewportSize({ width: 390, height: 844 }); + const mobile = await page.evaluate(() => ({ + pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1, + tableScrolls: document.querySelector(".popularity-table-frame-v2").scrollWidth > document.querySelector(".popularity-table-frame-v2").clientWidth + 1, + })); + expect(mobile).toEqual({ pageFits: true, tableScrolls: true }); +}); + +test("dragon-tiger redesign keeps the merged empty state and independent card hit zones", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockApplication(page, session("admin", true)); + await page.goto("/index.html"); + await page.locator('[data-view="dragonView"]').first().click(); + + await expect(page.locator("#dragonView")).toHaveClass(/redesigned-dragon-view/); + await expect(page.locator("#dragonEmptyState")).toBeVisible(); + await expect(page.locator("#dragonEmptyTitle")).toContainText("2026-07-22"); + await expect(page.locator("#dragonDailyContent")).toBeHidden(); + await expect(page.locator("#dragonPreviousButton")).toBeVisible(); + await expect(page.locator("#dragonEmptyRefreshButton")).toBeVisible(); + + await page.evaluate(() => { + const operation = (code, name, net) => ({ + code, name, change: net > 0 ? 6.8 : -2.4, buy_million: net > 0 ? 42 : 5, + sell_million: net > 0 ? 7 : 31, net_buy_million: net, + direction: net > 0 ? "买入" : "卖出", seat_name: "测试营业部", tag: "题材核心", reason: "日涨幅偏离值达标", + }); + const trader = (id, name, net, code) => ({ + id, name, identity_type: "trader", recognized: true, description: "聚焦市场核心,顺势参与强势方向", + stock_count: 1, operation_count: 1, buy_million: net > 0 ? 42 : 5, + sell_million: net > 0 ? 7 : 31, net_buy_million: net, + operations: [operation(code, `${name}标的`, net)], + }); + state.dragonTiger = { + meta: { trade_date: "2026-07-22", requested_date: "2026-07-22", status: "success" }, + summary: { trader_count: 3, operation_count: 3, seat_net_buy_million: 34, active_stock_count: 3 }, + traders: [trader("alpha", "甲游资", 35, "002141"), trader("beta", "乙游资", -26, "000001"), trader("gamma", "丙游资", 25, "000002")], + unclassified_seats: [], + }; + renderDragonTiger(); + }); + + await expect(page.locator("#dragonEmptyState")).toBeHidden(); + await expect(page.locator("#dragonDailyContent")).toBeVisible(); + await expect(page.locator("#dragonSummary .dragon-metric")).toHaveCount(4); + await expect(page.locator("#dragonTraderList .dragon-trader-card")).toHaveCount(3); + await expect(page.locator("#dragonTraderList .dragon-card-hit-zone")).toHaveCount(3); + await expect(page.locator("#dragonTraderDetail")).toBeVisible(); + await expect(page.locator("#dragonTraderDetail")).toContainText("甲游资"); + expect((await page.locator("#dragonTraderDetail .dragon-operation-table thead th").allTextContents()).map((text) => text.replace(/[↕▲▼]/g, ""))).toEqual([ + "序号", "股票", "方向", "涨幅(%)", "买入(百万)", "卖出(百万)", "净额(百万)", "关联席位", "标签 / 上榜原因", + ]); + const operationCells = page.locator("#dragonTraderDetail .dragon-operation-table tbody tr").first().locator("td"); + await expect(operationCells.nth(0)).toHaveText("1"); + await expect(operationCells.nth(1)).toContainText("002141"); + await expect(operationCells.nth(1)).toContainText("甲游资标的"); + + const hitZones = await page.locator("#dragonTraderList .dragon-card-hit-zone").evaluateAll((zones) => zones.map((zone) => { + const box = zone.getBoundingClientRect(); + return { left: box.left, right: box.right, width: box.width }; + })); + expect(hitZones.every((zone) => zone.width >= 18)).toBe(true); + expect(hitZones.slice(1).every((zone, index) => zone.left >= hitZones[index].right - 1)).toBe(true); + + await page.locator('[data-dragon-trader="gamma"]').hover(); + await expect(page.locator('[data-dragon-card="gamma"]')).toHaveClass(/hovered/); + await page.locator('[data-dragon-trader="beta"]').click(); + await expect(page.locator('[data-dragon-trader="beta"]')).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator("#dragonTraderDetail")).toContainText("乙游资"); + + await page.locator('[data-dragon-filter="buy"]').click(); + await expect(page.locator("#dragonTraderList .dragon-trader-card")).toHaveCount(2); + await page.locator('[data-dragon-filter="sell"]').click(); + await expect(page.locator("#dragonTraderList .dragon-trader-card")).toHaveCount(1); + await page.locator("#dragonSearch").fill("不存在"); + await expect(page.locator("#dragonTraderList .dragon-empty")).toBeVisible(); + await expect(page.locator("#dragonTraderDetail")).toBeHidden(); + await page.locator("#dragonSearch").fill(""); + await page.locator('[data-dragon-filter="all"]').click(); + await page.locator("#dragonTraderDetail tbody tr").first().click(); + await expect(page.locator("#stockDialog")).toBeVisible(); + await page.locator("#closeStockDialog").click(); + + const scrollOwnership = await page.evaluate(() => { + const body = document.querySelector("#dragonTraderDetail tbody"); + const seed = body.querySelector("tr"); + for (let index = 0; index < 20; index += 1) body.appendChild(seed.cloneNode(true)); + const daily = document.querySelector("#dragonDailyContent"); + const operations = document.querySelector("#dragonTraderDetail .trader-operations"); + return { + dailyOverflow: getComputedStyle(daily).overflowY, + dailyFits: daily.scrollHeight <= daily.clientHeight + 1, + operationOverflow: getComputedStyle(operations).overflowY, + operationsScroll: operations.scrollHeight > operations.clientHeight, + descriptionSize: parseFloat(getComputedStyle(document.querySelector("#dragonTraderDetail .dragon-detail-header p")).fontSize), + }; + }); + expect(scrollOwnership).toEqual({ + dailyOverflow: "hidden", + dailyFits: true, + operationOverflow: "auto", + operationsScroll: true, + descriptionSize: 13, + }); + + await page.setViewportSize({ width: 390, height: 844 }); + const mobile = await page.evaluate(() => ({ + pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1, + operationsScroll: document.querySelector("#dragonTraderDetail .trader-operations").scrollWidth > document.querySelector("#dragonTraderDetail .trader-operations").clientWidth + 1, + })); + expect(mobile).toEqual({ pageFits: true, operationsScroll: true }); +}); + +test("auction owns a single vertical scroll container across desktop densities", async ({ page }) => { + await page.setViewportSize({ width: 1920, height: 1080 }); + await mockApplication(page, session("admin", true)); + await page.goto("/index.html"); + await page.locator('[data-view="auctionView"]').first().click(); + await page.evaluate(() => { + const body = document.querySelector("#auctionTableBody"); + const seed = body.querySelector("tr"); + if (!seed) return; + for (let index = 0; index < 28; index += 1) body.appendChild(seed.cloneNode(true)); + }); + + const measure = () => page.evaluate(() => { + const box = (selector) => { + const element = document.querySelector(selector); + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return { + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + overflowY: style.overflowY, + top: rect.top, + bottom: rect.bottom, + width: rect.width, + }; + }; + return { + viewportHeight: innerHeight, + html: box("html"), + body: box("body"), + main: box(".app-main"), + view: box("#auctionView"), + primary: box(".auction-primary-card"), + frame: box(".auction-table-frame-v2"), + side: box(".auction-side-v2"), + }; + }); + + for (const viewport of [{ width: 1920, height: 1080 }, { width: 1536, height: 864 }]) { + await page.setViewportSize(viewport); + const layout = await measure(); + expect(layout.html.scrollHeight).toBeLessThanOrEqual(layout.viewportHeight); + expect(layout.body.scrollHeight).toBeLessThanOrEqual(layout.viewportHeight); + expect(layout.main.overflowY).toBe("hidden"); + expect(layout.frame.overflowY).toBe("auto"); + expect(layout.frame.scrollHeight).toBeGreaterThan(layout.frame.clientHeight); + expect(layout.side.top).toBeLessThan(layout.primary.bottom); + } + + await page.setViewportSize({ width: 1280, height: 720 }); + const compact = await measure(); + expect(compact.html.scrollHeight).toBeLessThanOrEqual(compact.viewportHeight); + expect(compact.main.overflowY).toBe("hidden"); + expect(compact.frame.overflowY).toBe("auto"); + expect(compact.frame.scrollHeight).toBeGreaterThan(compact.frame.clientHeight); + expect(compact.side.top).toBeLessThan(compact.primary.bottom); + + await page.setViewportSize({ width: 3840, height: 2160 }); + const wide = await measure(); + expect(wide.html.scrollHeight).toBeLessThanOrEqual(wide.viewportHeight); + expect(wide.view.width).toBeLessThanOrEqual(wide.main.clientWidth + 1); + expect(wide.view.scrollWidth).toBeLessThanOrEqual(wide.view.clientWidth + 1); +}); + test("regular account cannot see admin controls and member features are gated", async ({ page }) => { await mockApplication(page, session("user", false)); await page.goto("/index.html"); @@ -669,15 +1584,83 @@ test("mobile shell stays within the viewport", async ({ page }) => { }); test("new review workflows render account-scoped records", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); await mockApplication(page, session("user", true)); await page.goto("/index.html"); await page.locator('[data-view="screenerView"]').first().click(); + await expect(page.locator('[data-screener-step="regime"]')).toHaveAttribute("data-state", "complete"); + await page.locator("#openStrategyDrawerButton").click(); + await expect(page.locator("#strategyDrawer")).toBeVisible(); + await expect(page.locator("#strategyNameInput")).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(page.locator("#strategyDrawer")).toBeHidden(); await expect(page.locator("#trackingTableBody tr")).toHaveCount(1); await expect(page.locator("#trackingEmpty")).toBeHidden(); await page.locator('[data-view="reviewWorkspaceView"]').first().click(); + await expect(page.locator("#reviewDataDate")).toHaveText("2026-07-22"); + const reviewJournal = await page.locator("#reviewWorkspaceView .journal-section").boundingBox(); + const reviewLeft = await page.locator("#reviewWorkspaceView .review-left-stack").boundingBox(); + expect(Math.abs(reviewJournal.width - 360)).toBeLessThanOrEqual(1); + expect(reviewJournal.x).toBeGreaterThan(reviewLeft.x + reviewLeft.width - 2); + expect(Math.abs((reviewJournal.y + reviewJournal.height) - (reviewLeft.y + reviewLeft.height))).toBeLessThanOrEqual(1); + await expect(page.locator("#watchlistTableBody tr")).toHaveCount(1); + await expect(page.locator("#watchlistTableBody tr").first().locator("td")).toHaveCount(8); + await expect(page.locator("#watchlistTableBody")).toContainText("+1.86"); + await expect(page.locator("#watchlistTableBody")).toContainText("+8.92"); + await expect(page.locator("#watchlistTableBody")).toContainText("72.4"); + await expect(page.locator("#watchlistTableBody")).toContainText("观察承接,不追高"); + const watchColumns = await page.locator("#reviewWorkspaceView .review-watchlist-table thead th").evaluateAll((headers) => ({ + widths: headers.map((header) => Math.round(header.getBoundingClientRect().width)), + labels: headers.map((header) => header.textContent.replace(/[↕▲▼]/g, "").trim()), + numericAligned: headers.filter((header) => header.classList.contains("num")).every((header) => getComputedStyle(header).textAlign === "right"), + })); + expect(watchColumns.labels).toEqual(["标记", "股票", "所属板块", "今日涨幅(%)", "5日涨幅(%)", "竞价关注(分)", "跟踪备注", "操作"]); + expect(watchColumns.widths[6]).toBeGreaterThan(watchColumns.widths[3]); + expect(watchColumns.numericAligned).toBe(true); + await expect(page.locator("#journalSummary")).toHaveValue("缩量修复,主线仍待确认"); + await expect(page.locator("#journalContent")).toHaveValue("做对了等待确认。"); + await expect(page.locator("#journalPlan")).toHaveValue("只做有承接的核心。"); + const journalSpacing = await page.evaluate(() => { + const textarea = document.querySelector("#journalContent"); + const label = textarea.previousElementSibling; + const labelBox = label.getBoundingClientRect(); + const textareaBox = textarea.getBoundingClientRect(); + return { labelHeight: Math.round(labelBox.height), gap: Math.round(textareaBox.top - labelBox.bottom) }; + }); + expect(journalSpacing).toEqual({ labelHeight: 18, gap: 6 }); + await page.screenshot({ path: "test-results/review-stage17-1440.png", fullPage: true }); + await page.locator("#openWatchlistDialog").click(); + await expect(page.locator("#watchlistDialog")).toBeVisible(); + await page.locator("#watchlistSearchInput").fill("002141"); + await expect(page.locator("#watchlistSearchResults [data-watchlist-result]")).toHaveCount(1); + await page.locator("#watchlistSearchResults [data-watchlist-result]").click(); + await expect(page.locator("#watchlistSelectionName")).toHaveText("Test Stock"); + await page.locator("#watchlistRemark").fill("等待放量确认"); + await page.locator("#saveWatchlist").click(); + await expect(page.locator("#watchlistDialog")).toBeHidden(); + await expect(page.locator("#tradeLogTableBody tr").first().locator("td")).toHaveCount(9); + const tradeColumns = await page.locator("#reviewWorkspaceView .trade-log-table thead th").evaluateAll((headers) => ({ + widths: headers.map((header) => Math.round(header.getBoundingClientRect().width)), + labels: headers.map((header) => header.textContent.replace(/[↕▲▼]/g, "").trim()), + })); + expect(tradeColumns.labels).toEqual(["日期", "股票", "动作", "仓位(%)", "盈亏(%)", "盈亏金额(元)", "情绪 / 标签", "交易复核", "操作"]); + expect(tradeColumns.widths[7]).toBeGreaterThan(tradeColumns.widths[3]); + expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1); + await expect(page.locator("#reviewHistoryPanel")).toBeHidden(); + await page.locator("#reviewHistoryToggle").click(); + await expect(page.locator("#reviewHistoryPanel")).toBeVisible(); + await expect(page.locator("#reviewHistoryToggle")).toHaveAttribute("aria-expanded", "true"); await expect(page.locator("#tradeLogTableBody tr")).toHaveCount(1); + const tradeScroll = await page.evaluate(() => { + const seed = state.tradeEntries[0]; + state.tradeEntries = Array.from({ length: 24 }, (_, index) => ({ ...seed, id: index + 1 })); + renderTradeLog(); + const frame = document.querySelector("#reviewWorkspaceView .trade-log-table-frame"); + return { scrollHeight: frame.scrollHeight, clientHeight: frame.clientHeight }; + }); + expect(tradeScroll.scrollHeight).toBeGreaterThan(tradeScroll.clientHeight); await expect(page.locator("#tradeLogEmpty")).toBeHidden(); await expect(page.locator("#tradeLogForm")).toBeHidden(); await page.locator("#openTradeLogDialog").click(); @@ -694,6 +1677,13 @@ test("new review workflows render account-scoped records", async ({ page }) => { await page.locator("#cancelTradeEdit").click(); await expect(page.locator("#tradeLogDialog")).toBeHidden(); + await page.setViewportSize({ width: 375, height: 812 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1); + const mobileLeft = await page.locator("#reviewWorkspaceView .review-left-stack").boundingBox(); + const mobileJournal = await page.locator("#reviewWorkspaceView .journal-section").boundingBox(); + expect(mobileJournal.y).toBeGreaterThanOrEqual(mobileLeft.y + mobileLeft.height - 2); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.locator("#alertButton").click(); await expect(page.locator("#alertList .alert-item")).toHaveCount(1); await expect(page.locator("#alertBadge")).toHaveText("1"); @@ -706,6 +1696,302 @@ test("new review workflows render account-scoped records", async ({ page }) => { await expect(page.locator("#assistantQuestion")).toBeEnabled(); }); +test("curated strategies and quant builder form independent screener workspaces", async ({ page }) => { + await mockApplication(page, session("user", true)); + await page.goto("/index.html"); + await page.locator('[data-view="screenerView"]').first().click(); + + await page.locator('[data-screener-mode="curated"]').click(); + await expect(page.locator('[data-screener-panel="smart"]')).toBeHidden(); + await expect(page.locator('[data-screener-panel="curated"]')).toBeVisible(); + await expect(page.locator("#curatedStrategyList .curated-strategy-card")).toHaveCount(1); + await expect(page.locator("#curatedStrategyName")).toHaveText("连续分红质量"); + await expect(page.locator("#curatedFilterList .curated-rule-row")).toHaveCount(1); + await expect(page.locator("#curatedRunButton")).toBeEnabled(); + + await page.locator('[data-screener-mode="quant"]').click(); + await expect(page.locator('[data-screener-panel="curated"]')).toBeHidden(); + await expect(page.locator('[data-screener-panel="quant"]')).toBeVisible(); + await expect(page.locator("#quantFilterRows .quant-filter-row")).toHaveCount(2); + await expect(page.locator("#quantScoreRows .quant-score-row")).toHaveCount(5); + await expect(page.locator('#quantScoreRows input[data-quant-key="weight"]').first()).toHaveAttribute("type", "range"); + await expect(page.locator("#quantWeightTotal")).toHaveText("100%"); + await expect(page.locator("#quantRunButton")).toBeEnabled(); + + await page.locator("#addQuantFilterButton").click(); + await expect(page.locator("#quantFilterRows .quant-filter-row")).toHaveCount(3); + const lastScore = page.locator("#quantScoreRows .quant-score-row").last(); + await expect(lastScore.locator('[data-quant-action="direction"]')).toHaveText("数值越低越优"); + await lastScore.locator('[data-quant-action="direction"]').click(); + await expect(lastScore.locator('[data-quant-action="direction"]')).toHaveText("数值越高越优"); +}); + +test("screener refresh restores every mode and deduplicates setup loading", async ({ page }) => { + const candidate = (code, name) => ({ + code, name, sector: "测试板块", score_display: 80, historical_probability: 50, + probability_samples: 20, pct_chg: 1, return_5d: 2, volume_ratio_5d: 1.2, + sector_strength: 70, reason: "测试来源", risk_flags: [], + }); + const result = (mode, runId, strategyName, row) => ({ + meta: { + run_id: runId, trade_date: "20260722", regime: "repair", + strategy_name: strategyName, mode, + }, + candidates: [row], + disclaimer: "历史统计不代表未来收益", + backtest: null, + }); + const options = { + latestScreenerResults: { + smart: result("smart", 61, "修复确认", candidate("600001", "阶段恢复")), + curated: result("curated", 62, "连续分红质量", candidate("600002", "策略恢复")), + quant: result("quant", 63, "自定义量化公式", candidate("600003", "量化恢复")), + }, + }; + await mockApplication(page, session("user", true), options); + await page.goto("/index.html?view=screenerView"); + await expect(page.locator("#screenerTableBody")).toContainText("阶段恢复"); + expect(options.screenerSetupRequests).toBe(1); + + await page.locator('[data-screener-mode="curated"]').click(); + await expect(page.locator("#curatedStrategyName")).toHaveText("连续分红质量"); + await expect(page.locator("#screenerTableBody")).toContainText("策略恢复"); + await page.locator('[data-screener-mode="quant"]').click(); + await expect(page.locator("#screenerTableBody")).toContainText("量化恢复"); + + await page.reload(); + await expect(page.locator('[data-screener-mode="quant"]')).toHaveClass(/active/); + await expect(page.locator("#screenerTableBody")).toContainText("量化恢复"); + expect(options.screenerSetupRequests).toBe(2); + + await page.locator("#quantRunButton").click(); + await expect.poll(() => options.screenerRunBodies?.length || 0).toBe(1); + expect(options.screenerRunBodies[0].mode).toBe("quant"); +}); + +test("screener redesign preserves three clear workspaces across desktop and mobile", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockApplication(page, session("user", true)); + await page.goto("/index.html"); + await page.locator('[data-view="screenerView"]').first().click(); + + await expect(page.locator("#screenerView .screener-page-bar")).toBeVisible(); + await expect(page.locator("#screenerView .screener-step")).toHaveCount(4); + await expect(page.locator("#regimeLabel")).toHaveText("修复"); + await expect(page.locator("#screenerView .regime-temperature")).toHaveCount(0); + await expect(page.locator("#regimeEvidenceList")).toContainText("情绪温度"); + const overviewCards = page.locator("#screenerView .screener-overview-card"); + await expect(overviewCards).toHaveCount(2); + const [regimeBox, strategyBox] = await Promise.all([ + overviewCards.nth(0).boundingBox(), + overviewCards.nth(1).boundingBox(), + ]); + expect(Math.abs(regimeBox.y - strategyBox.y)).toBeLessThanOrEqual(1); + expect(strategyBox.x).toBeGreaterThan(regimeBox.x + regimeBox.width - 2); + expect(regimeBox.height).toBeLessThanOrEqual(155); + expect(strategyBox.height).toBeLessThanOrEqual(155); + for (const line of await page.locator("#screenerView .step-line").all()) { + expect((await line.boundingBox()).width).toBeLessThanOrEqual(42); + } + const [strategyDescriptionBox, strategyActionsBox] = await Promise.all([ + page.locator("#activeStrategyDescription").boundingBox(), + page.locator(".screener-strategy-actions").boundingBox(), + ]); + expect(strategyActionsBox.y - (strategyDescriptionBox.y + strategyDescriptionBox.height)).toBeLessThanOrEqual(8); + const trackingEntryStyle = await page.locator("#openScreenerTrackingButton").evaluate((element) => { + const style = getComputedStyle(element); + return { background: style.backgroundColor, weight: style.fontWeight }; + }); + expect(trackingEntryStyle.background).not.toBe("rgb(255, 255, 255)"); + expect(Number(trackingEntryStyle.weight)).toBeGreaterThanOrEqual(700); + await page.evaluate(() => { + setScreenerResult("smart", { + meta: { run_id: 46, trade_date: "20260722", realtime: false, regime: "repair", strategy_name: "修复确认" }, + disclaimer: "历史统计不代表未来收益", + candidates: [{ + code: "600000", ts_code: "600000.SH", name: "浦发银行", sector: "银行", price: 12, + score_display: 82, historical_probability: 45, probability_samples: 30, + pct_chg: 1.2, return_5d: 3.4, volume_ratio_5d: 1.5, sector_strength: 78, + reason: "板块强度、相对强度", risk_flags: [], + }], + backtest: { + samples: 35, win_rate: 8.6, average_3d_return: -5, + average_drawdown: -11.71, definition: "收盘后选股,未来3日按统一阈值验证。", + }, + }, { regime: "repair", strategyId: 1, strategyName: "修复确认" }); + renderScreenerResult(); + }); + const completedMarkers = page.locator('#screenerView .screener-step[data-state="complete"] .step-marker'); + await expect(completedMarkers).toHaveCount(3); + await expect(page.locator("#backtestPanel")).toBeVisible(); + expect((await page.locator("#backtestPanel").boundingBox()).height).toBeLessThanOrEqual(55); + await expect(page.locator("#backtestMetrics .dragon-metric span")).toHaveCount(4); + await expect(page.locator("#screenerResultSource")).toHaveText("阶段选股 · 修复 · 修复确认"); + for (const metric of await page.locator("#backtestMetrics .dragon-metric").all()) { + const box = await metric.boundingBox(); + expect(box.height).toBeLessThanOrEqual(40); + } + await page.screenshot({ path: "test-results/screener-stage15-phase-1440.png", fullPage: true }); + + await page.evaluate(() => { + state.screenerSetup.strategies.push({ + id: 4, name: "低波质量", description: "用第二套策略验证整卡选择交互。", regimes: ["repair"], builtin: true, + data_ready: true, missing_data: [], + formula: { + meta: { library: "curated", category: "质量防守", quality: "A", frequency: "月度", risk: "低", data_group: "行情与质量" }, + universe: { exclude_st: true, listed_days_min: 720 }, + filters: [{ field: "return_20d", op: ">=", value: 0 }], + score: [{ field: "relative_strength", weight: 1, direction: "desc" }], limit: 20, min_score: 0.5, + }, + }); + renderCuratedStrategyLibrary(); + }); + await page.locator('[data-screener-mode="curated"]').click(); + await expect(page.locator("#curatedStrategyList .curated-strategy-card")).toHaveCount(2); + await expect(page.locator("#curatedDetailDialog")).toBeHidden(); + const secondStrategy = page.locator('#curatedStrategyList [data-curated-strategy="4"]'); + await secondStrategy.locator(".curated-card-description").click(); + await expect(secondStrategy).toHaveClass(/active/); + await expect(page.locator('#curatedStrategyList [data-curated-strategy="2"]')).not.toHaveClass(/active/); + await secondStrategy.locator('[data-curated-inspect="4"]').click(); + await expect(page.locator("#curatedDetailDialog")).toBeVisible(); + const dialogBox = await page.locator("#curatedDetailDialog").boundingBox(); + expect(Math.abs(dialogBox.x + dialogBox.width / 2 - 720)).toBeLessThanOrEqual(2); + expect(Math.abs(dialogBox.y + dialogBox.height / 2 - 450)).toBeLessThanOrEqual(2); + await page.locator("#closeCuratedDetailButton").click(); + await page.screenshot({ path: "test-results/screener-stage15-strategy-1440.png", fullPage: true }); + + await page.locator('[data-screener-mode="quant"]').click(); + await expect(page.locator("#quantScoreRows .quant-score-row")).toHaveCount(5); + await expect(page.locator("#screenerView .quant-intro-band")).toHaveCount(0); + await expect(page.getByText("执行设置", { exact: true })).toHaveCount(0); + await expect(page.locator("#screenerResultTitle")).toHaveText("打分结果"); + const [builderBox, summaryBox] = await Promise.all([ + page.locator(".quant-builder-pane").boundingBox(), + page.locator(".quant-summary-pane").boundingBox(), + ]); + expect(Math.abs(builderBox.y - summaryBox.y)).toBeLessThanOrEqual(1); + expect(summaryBox.x).toBeGreaterThan(builderBox.x + builderBox.width - 2); + expect(builderBox.width).toBeGreaterThanOrEqual(395); + expect(builderBox.width).toBeLessThanOrEqual(405); + const quantRunBox = await page.locator("#quantRunButton").boundingBox(); + expect(quantRunBox.width).toBeLessThan(180); + expect((await page.locator("#quantFilterRows .quant-filter-row select").first().boundingBox()).width).toBeLessThanOrEqual(225); + await expect(page.locator('[data-screener-results-slot="quant"] > .screener-results-view')).toBeVisible(); + await page.screenshot({ path: "test-results/screener-stage15-quant-1440.png", fullPage: true }); + + await page.setViewportSize({ width: 375, height: 812 }); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + expect(overflow).toBeLessThanOrEqual(1); + await expect(page.locator("#screenerView .screener-mode-tabs")).toBeVisible(); +}); + +test("screener stage completion follows its execution context and mode results stay isolated", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockApplication(page, session("user", true)); + await page.goto("/index.html"); + await page.locator('[data-view="screenerView"]').first().click(); + await page.evaluate(() => { + state.screenerSetup.regimes.push({ id: "retreat", label: "退潮" }); + state.screenerSetup.strategies.push({ + id: 3, name: "退潮防守", description: "仅保留抗跌方向", regimes: ["retreat"], builtin: true, + data_ready: true, missing_data: [], + formula: { meta: { library: "smart" }, universe: {}, filters: [], score: [], limit: 10, min_score: 0.5 }, + }); + const candidate = (code, name) => ({ + code, name, sector: "测试板块", score_display: 80, historical_probability: 50, + probability_samples: 20, pct_chg: 1, return_5d: 2, volume_ratio_5d: 1.2, + sector_strength: 70, reason: "测试来源", risk_flags: [], + }); + window.__screenerCandidate = candidate; + setScreenerResult("smart", { + meta: { run_id: 51, trade_date: "20260722", regime: "repair", strategy_name: "修复确认" }, + disclaimer: "历史统计不代表未来收益", candidates: [candidate("600001", "阶段结果")], + backtest: { samples: 37, win_rate: 50, average_3d_return: 1, average_drawdown: -2, definition: "测试" }, + }, { regime: "repair", strategyId: 1, strategyName: "修复确认" }); + renderScreenerSetup(); + }); + + await expect(page.locator('#screenerView .screener-step[data-state="complete"]')).toHaveCount(3); + await expect(page.locator("#screenerTableBody")).toContainText("阶段结果"); + await page.locator('[data-regime="retreat"]').click(); + await expect(page.locator('#screenerView .screener-step[data-state="complete"]')).toHaveCount(2); + await expect(page.locator("#screenerRunStatus")).toHaveText("等待执行"); + await expect(page.locator("#backtestTaskStatus")).toHaveText("随选股执行"); + await expect(page.locator("#screenerEmpty")).toContainText("当前阶段与策略"); + await expect(page.locator("#screenerResultSource")).toBeHidden(); + + await page.locator('[data-regime="repair"]').click(); + await expect(page.locator("#screenerTableBody")).toContainText("阶段结果"); + await page.locator('[data-screener-mode="curated"]').click(); + await expect(page.locator("#screenerEmpty")).toContainText("所选策略"); + await page.evaluate(() => { + setScreenerResult("curated", { + meta: { run_id: 52, trade_date: "20260722", regime: "repair", strategy_name: "连续分红质量" }, + disclaimer: "历史统计不代表未来收益", candidates: [window.__screenerCandidate("600002", "策略结果")], + }, { regime: "repair", strategyId: 2, strategyName: "连续分红质量" }); + renderScreenerResult(); + }); + await expect(page.locator("#screenerTableBody")).toContainText("策略结果"); + await expect(page.locator("#screenerTableBody")).not.toContainText("阶段结果"); + await expect(page.locator("#screenerResultSource")).toHaveText("策略选股 · 修复 · 连续分红质量"); + + await page.locator('[data-screener-mode="quant"]').click(); + await expect(page.locator("#screenerEmpty")).toContainText("量化选股"); + await page.evaluate(() => { + setScreenerResult("quant", { + meta: { run_id: 53, trade_date: "20260722", regime: "repair", strategy_name: "自定义量化公式" }, + disclaimer: "历史统计不代表未来收益", candidates: [window.__screenerCandidate("600003", "量化结果")], + }, { regime: "repair", strategyName: "自定义量化公式" }); + renderScreenerResult(); + }); + await expect(page.locator("#screenerTableBody")).toContainText("量化结果"); + await expect(page.locator("#screenerResultSource")).toHaveText("量化选股 · 自定义因子权重"); + + await page.locator('[data-screener-mode="smart"]').click(); + await expect(page.locator("#screenerTableBody")).toContainText("阶段结果"); + await expect(page.locator("#screenerTableBody")).not.toContainText("策略结果"); + await expect(page.locator("#screenerTableBody")).not.toContainText("量化结果"); +}); + +test("screener tracking is an internal page populated only by manual candidate actions", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockApplication(page, session("user", true)); + await page.goto("/index.html"); + await page.locator('[data-view="screenerView"]').first().click(); + await page.evaluate(() => { + setScreenerResult("smart", { + meta: { run_id: 45, trade_date: "20260722", realtime: false, regime: "repair", strategy_name: "修复确认" }, + disclaimer: "历史统计不代表未来收益", + candidates: [{ + code: "600000", ts_code: "600000.SH", name: "浦发银行", sector: "银行", price: 12, + score_display: 82, historical_probability: 45, probability_samples: 30, + pct_chg: 1.2, return_5d: 3.4, volume_ratio_5d: 1.5, sector_strength: 78, + reason: "板块强度、相对强度", risk_flags: [], + }], + }, { regime: "repair", strategyId: 1, strategyName: "修复确认" }); + renderScreenerResult(); + }); + + const addButton = page.locator('[data-add-tracking="600000"]'); + await expect(addButton).toHaveText("加入跟踪"); + await addButton.click(); + await expect(page.locator('[data-add-tracking="600000"]')).toHaveText("已跟踪"); + + await page.locator("#openScreenerTrackingButton").click(); + await expect(page.locator("#screenerTrackingView")).toHaveClass(/active-view/); + await expect(page.locator('[data-view="screenerView"]').first()).toHaveClass(/active/); + await expect(page.locator("#trackingTableBody tr")).toHaveCount(2); + await expect(page.locator('[data-view="screenerTrackingView"]')).toHaveCount(0); + await page.screenshot({ path: "test-results/screener-stage15-tracking-1440.png", fullPage: true }); + + page.once("dialog", (dialog) => dialog.accept()); + await page.locator('[data-remove-tracking="10"]').click(); + await expect(page.locator("#trackingTableBody tr")).toHaveCount(1); + await page.locator("#closeScreenerTrackingButton").click(); + await expect(page.locator("#screenerView")).toHaveClass(/active-view/); +}); + for (const viewport of [ { name: "portrait", width: 375, height: 812 }, { name: "landscape", width: 812, height: 375 }, @@ -838,6 +2124,7 @@ test("mentor directory exposes evidence filters and private owner metadata", asy await page.goto("/index.html"); await page.locator('[data-view="mentorView"]').first().click(); + await expect(page.locator("#mentorView .mentor-page-header .mentor-evidence-filters")).toBeVisible(); await expect(page.locator("#mentorList .mentor-option")).toHaveCount(22); await expect(page.locator('#mentorList [data-mentor-id="private-owner"] .mentor-badge.private')).toContainText("仅自己"); await expect(page.locator('#mentorList [data-mentor-id="source-a"] .mentor-badge.grade-a')).toHaveText("A"); @@ -853,6 +2140,25 @@ test("mentor directory exposes evidence filters and private owner metadata", asy await expect(page.locator("#activeMentorName")).toHaveText("多源老师"); await expect(page.locator("#activeMentorBadges")).toContainText("B · 多源整理"); await expect(page.locator("#activeMentorEvidence")).toHaveText("公开访谈与多源材料"); + const mentorLibrary = await page.locator("#mentorView .mentor-sidebar").boundingBox(); + const mentorChat = await page.locator("#mentorView .mentor-chat-panel").boundingBox(); + const mentorInput = await page.locator("#mentorQuestion").boundingBox(); + expect(Math.abs(mentorLibrary.width - 340)).toBeLessThanOrEqual(1); + expect(mentorChat.x - (mentorLibrary.x + mentorLibrary.width)).toBeGreaterThanOrEqual(11); + expect(mentorInput.height).toBeLessThanOrEqual(40); + expect(await page.evaluate(() => document.documentElement.scrollHeight - window.innerHeight)).toBeLessThanOrEqual(1); + await page.setViewportSize({ width: 1920, height: 947 }); + const expandedMentorLayout = await page.locator("#mentorView .mentor-layout").boundingBox(); + const statusBar = await page.locator(".status-bar").boundingBox(); + const lowerGap = statusBar.y - (expandedMentorLayout.y + expandedMentorLayout.height); + expect(lowerGap).toBeGreaterThanOrEqual(0); + expect(lowerGap).toBeLessThanOrEqual(8); + await page.locator("#overviewToggle").click(); + const openOverviewLayout = await page.locator("#mentorView .mentor-layout").boundingBox(); + const openOverviewGap = statusBar.y - (openOverviewLayout.y + openOverviewLayout.height); + expect(openOverviewGap).toBeGreaterThanOrEqual(0); + expect(openOverviewGap).toBeLessThanOrEqual(8); + expect(await page.evaluate(() => document.documentElement.scrollHeight - window.innerHeight)).toBeLessThanOrEqual(1); }); test("mentor pins, custom order and streamed replies work together", async ({ page }) => { @@ -895,3 +2201,73 @@ test("mobile mentor directory opens as a searchable selector and hides private m await expect(page.locator("#mobileActiveMentorName")).toHaveText("推演老师"); expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1); }); + +test("global dialogs share the stage 18 geometry without changing account or admin access", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockApplication(page, session("admin", true)); + await page.goto("/index.html"); + + await page.keyboard.press("Control+K"); + await expect(page.locator("#globalSearchDialog")).toHaveAttribute("aria-labelledby", "globalSearchTitle"); + expect((await page.locator("#globalSearchDialog").boundingBox()).width).toBeLessThanOrEqual(662); + await page.locator("#closeGlobalSearch").click(); + + await page.evaluate(() => openStock("002141", { code: "002141", name: "贤丰控股", sector: "元件" })); + const stockGeometry = await page.locator("#stockDialog").evaluate((dialog) => ({ + width: dialog.getBoundingClientRect().width, + height: dialog.getBoundingClientRect().height, + overflowY: getComputedStyle(dialog).overflowY, + headerPosition: getComputedStyle(dialog.querySelector(".dialog-header")).position, + documentOverflow: document.documentElement.scrollWidth - window.innerWidth, + })); + expect(stockGeometry.width).toBeLessThanOrEqual(812); + expect(stockGeometry.height).toBeLessThanOrEqual(878); + expect(stockGeometry.overflowY).toBe("auto"); + expect(stockGeometry.headerPosition).toBe("sticky"); + expect(stockGeometry.documentOverflow).toBeLessThanOrEqual(1); + await page.locator("#closeStockDialog").click(); + + await page.locator("#alertButton").click(); + await expect(page.locator("#alertsDialog")).toHaveAttribute("aria-labelledby", "alertsDialogTitle"); + expect((await page.locator("#alertsDialog").boundingBox()).width).toBeLessThanOrEqual(722); + await page.locator("#closeAlertsDialog").click(); + + await page.locator("#assistantButton").click(); + const assistantGeometry = await page.locator("#assistantDialog").evaluate((dialog) => ({ + height: dialog.getBoundingClientRect().height, + overflowY: getComputedStyle(dialog).overflowY, + contentDisplay: getComputedStyle(dialog.querySelector(".assistant-member-content")).display, + })); + expect(assistantGeometry.height).toBeLessThanOrEqual(762); + expect(assistantGeometry.overflowY).toBe("hidden"); + expect(assistantGeometry.contentDisplay).toBe("flex"); + await page.locator("#closeAssistantDialog").click(); + + await page.locator("#accountButton").click(); + await page.locator('[data-account-panel="membership"]').click(); + await expect(page.locator("#settingsDialog")).toHaveAttribute("aria-labelledby", "accountDialogTitle"); + await expect(page.locator("#accountDialogTitle")).toHaveText("会员状态"); + const accountBox = await page.locator("#settingsDialog").boundingBox(); + expect(accountBox.width).toBeLessThanOrEqual(722); + expect(Math.abs(accountBox.x + accountBox.width / 2 - 720)).toBeLessThanOrEqual(2); + expect(Math.abs(accountBox.y + accountBox.height / 2 - 450)).toBeLessThanOrEqual(2); + await page.locator("#closeSettingsDialog").click(); + + await page.locator("#settingsButton").click(); + await expect(page.locator("#adminDialog")).toHaveAttribute("aria-labelledby", "adminDialogTitle"); + await expect(page.locator("#adminSectionSelect")).toBeVisible(); + const adminBox = await page.locator("#adminDialog").boundingBox(); + expect(adminBox.width).toBeLessThanOrEqual(902); + expect(Math.abs(adminBox.x + adminBox.width / 2 - 720)).toBeLessThanOrEqual(2); + expect(Math.abs(adminBox.y + adminBox.height / 2 - 450)).toBeLessThanOrEqual(2); + await page.locator("#closeAdminDialog").click(); + + await page.setViewportSize({ width: 375, height: 812 }); + await page.locator("#alertButton").click(); + const mobileGeometry = await page.locator("#alertsDialog").evaluate((dialog) => ({ + width: dialog.getBoundingClientRect().width, + documentOverflow: document.documentElement.scrollWidth - window.innerWidth, + })); + expect(mobileGeometry.width).toBeLessThanOrEqual(375); + expect(mobileGeometry.documentOverflow).toBeLessThanOrEqual(1); +}); diff --git a/tests/test_account_access.py b/tests/test_account_access.py index b66f37f..dfd8d6f 100644 --- a/tests/test_account_access.py +++ b/tests/test_account_access.py @@ -120,10 +120,13 @@ class AccountAccessTests(unittest.TestCase): first = self.database.create_user("note_owner", "salt", "hash") second = self.database.create_user("other_reader", "salt", "hash") note_id = self.database.save_note( - first["id"], "002141", "贤丰控股", "20260721", "只属于甲", "明日观察" + first["id"], "002141", "贤丰控股", "20260721", "只属于甲", "明日观察", + summary="市场缩量修复", ) - self.assertEqual(len(self.database.list_notes(first["id"], code="002141")), 1) + first_notes = self.database.list_notes(first["id"], code="002141") + self.assertEqual(len(first_notes), 1) + self.assertEqual(first_notes[0]["summary"], "市场缩量修复") self.assertEqual(self.database.list_notes(second["id"], code="002141"), []) with self.assertRaises(ValueError): self.database.save_note( @@ -141,10 +144,13 @@ class AccountAccessTests(unittest.TestCase): def test_watchlist_is_scoped_to_its_owner(self): first = self.database.create_user("watch_owner", "salt", "hash") second = self.database.create_user("other_watcher", "salt", "hash") - self.database.save_watchlist(first["id"], "002141", "贤丰控股", "元件", "red") + self.database.save_watchlist( + first["id"], "002141", "贤丰控股", "元件", "red", "观察承接" + ) self.database.save_watchlist(second["id"], "002141", "贤丰控股", "元件", "blue") self.assertEqual(self.database.list_watchlist(first["id"])[0]["color"], "red") + self.assertEqual(self.database.list_watchlist(first["id"])[0]["remark"], "观察承接") self.assertEqual(self.database.list_watchlist(second["id"])[0]["color"], "blue") self.assertFalse(self.database.delete_watchlist(second["id"], "000001")) self.assertTrue(self.database.delete_watchlist(first["id"], "002141")) diff --git a/tests/test_account_data_boundaries.py b/tests/test_account_data_boundaries.py index d636774..ff60374 100644 --- a/tests/test_account_data_boundaries.py +++ b/tests/test_account_data_boundaries.py @@ -52,6 +52,33 @@ class AccountDataBoundaryTests(unittest.TestCase): ) self.assertIsNone(self.database.latest_screener_run(self.second["id"], "20260722")) + def test_latest_screener_runs_are_isolated_by_mode_and_user(self): + expected = { + "smart": "600001", + "curated": "600002", + "quant": "600003", + } + for mode, code in expected.items(): + self.database.save_screener_run( + self.first["id"], "20260721", "repair", f"{mode}-strategy", FORMULA, + {"candidates": [{"code": code}], "meta": {}}, mode, + ) + + results = self.database.latest_screener_runs(self.first["id"], "20260722") + self.assertEqual(set(results), set(expected)) + for mode, code in expected.items(): + self.assertEqual(results[mode]["meta"]["mode"], mode) + self.assertEqual(results[mode]["candidates"][0]["code"], code) + self.assertEqual( + self.database.latest_screener_run( + self.first["id"], "20260722", mode + )["candidates"][0]["code"], + code, + ) + self.assertEqual( + self.database.latest_screener_runs(self.second["id"], "20260722"), {} + ) + def test_mentor_messages_are_scoped_by_user_mentor_and_date(self): self.database.save_mentor_exchange( self.first["id"], "mentor-a", "20260721", "怎么看?", "先看承接。", "20260721" @@ -164,7 +191,12 @@ class LegacyStrategyMigrationTests(unittest.TestCase): created_at TEXT NOT NULL ); INSERT INTO screener_runs VALUES - (1, '20260721', 'repair', '旧策略', '{}', '{"meta":{}}', '2026-07-21'); + (1, '20260721', 'repair', '旧策略', '{}', '{"meta":{}}', '2026-07-21'), + (2, '20260721', 'repair', '旧精选策略', + '{"meta":{"library":"curated"}}', '{"meta":{}}', '2026-07-21'), + (3, '20260721', 'repair', '自定义量化公式', + '{"meta":{"library":"custom","category":"量化公式"}}', + '{"meta":{}}', '2026-07-21'); """ ) connection.commit() @@ -177,6 +209,10 @@ class LegacyStrategyMigrationTests(unittest.TestCase): self.assertEqual(owners["旧策略"], 1) self.assertIsNone(owners["旧内置"]) self.assertIsNotNone(migrated.latest_screener_run(1, "20260722")) + self.assertEqual( + set(migrated.latest_screener_runs(1, "20260722")), + {"smart", "curated", "quant"}, + ) class PublicKnowledgePermissionTests(unittest.TestCase): diff --git a/tests/test_api_access.py b/tests/test_api_access.py index 74184f8..36f4f67 100644 --- a/tests/test_api_access.py +++ b/tests/test_api_access.py @@ -15,12 +15,14 @@ class ApiAccessPolicyTests(unittest.TestCase): ("GET", "/api/heaven/readings"): "member", ("GET", "/api/assistant/messages"): "member", ("POST", "/api/screener/run"): "member", + ("POST", "/api/screener/tracking"): "member", ("POST", "/api/screener/tracking/refresh"): "member", ("POST", "/api/mentors/chat"): "member", ("POST", "/api/mentors/preferences"): "member", ("POST", "/api/heaven/interpret"): "member", ("POST", "/api/assistant/chat"): "member", ("DELETE", "/api/screener/strategies/42"): "member", + ("DELETE", "/api/screener/tracking/42"): "member", ("DELETE", "/api/mentors/messages"): "member", ("DELETE", "/api/assistant/messages"): "member", ("DELETE", "/api/heaven/readings/42"): "member", diff --git a/tests/test_curated_screener.py b/tests/test_curated_screener.py new file mode 100644 index 0000000..ac1db6e --- /dev/null +++ b/tests/test_curated_screener.py @@ -0,0 +1,114 @@ +import sqlite3 +import tempfile +import unittest +from pathlib import Path + +from database import ReviewDatabase +from screener import ( + CURATED_STRATEGIES, + FACTOR_FIELDS, + FACTOR_GROUPS, + ScreenerEngine, + _quarter_periods, +) + + +class CuratedScreenerTests(unittest.TestCase): + def test_first_batch_contains_ten_distinct_curated_strategies(self): + self.assertEqual(10, len(CURATED_STRATEGIES)) + self.assertEqual(10, len({item["name"] for item in CURATED_STRATEGIES})) + self.assertTrue( + all(item["formula"]["meta"]["library"] == "curated" for item in CURATED_STRATEGIES) + ) + + def test_every_curated_formula_uses_supported_factors(self): + with tempfile.TemporaryDirectory() as root: + database = ReviewDatabase(Path(root) / "review.db") + engine = ScreenerEngine(database) + for strategy in CURATED_STRATEGIES: + formula = engine.validate_formula(strategy["formula"]) + fields = { + item["field"] + for item in formula["filters"] + formula["score"] + } + self.assertTrue(fields.issubset(FACTOR_FIELDS), strategy["name"]) + + def test_factor_groups_cover_every_quant_factor(self): + grouped = [field for fields in FACTOR_GROUPS.values() for field in fields] + self.assertEqual(set(FACTOR_FIELDS), set(grouped)) + self.assertEqual(len(grouped), len(set(grouped))) + + def test_database_migrates_valuation_and_fundamental_columns(self): + with tempfile.TemporaryDirectory() as root: + path = Path(root) / "review.db" + ReviewDatabase(path) + connection = sqlite3.connect(path) + try: + indicator_columns = { + row[1] for row in connection.execute("PRAGMA table_info(daily_indicators)") + } + tables = { + row[0] for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + } + finally: + connection.close() + self.assertTrue({"pe_ttm", "pb", "ps_ttm", "dv_ttm"}.issubset(indicator_columns)) + self.assertIn("fundamental_indicators", tables) + + def test_quarter_periods_stop_at_selected_date(self): + periods = _quarter_periods("20260722", 5) + self.assertEqual( + ["20250630", "20250930", "20251231", "20260331", "20260630"], + periods, + ) + + def test_factor_health_summary_uses_availability_counts(self): + with tempfile.TemporaryDirectory() as root: + database = ReviewDatabase(Path(root) / "review.db") + with database.connect() as connection: + connection.execute( + "INSERT INTO daily_bars (trade_date, ts_code) VALUES (?, ?)", + ("20260722", "600000.SH"), + ) + connection.execute( + """ + INSERT INTO daily_indicators + (trade_date, ts_code, pe_ttm) + VALUES (?, ?, ?) + """, + ("20260722", "600000.SH", 8.5), + ) + connection.executemany( + "INSERT INTO daily_indicators (trade_date, ts_code) VALUES (?, ?)", + [(f"{year}1231", f"{year % 100:02d}0000.SZ") for year in range(2022, 2026)], + ) + connection.execute( + "INSERT INTO auction_factors (trade_date, ts_code) VALUES (?, ?)", + ("20260722", "600000.SH"), + ) + connection.executemany( + """ + INSERT INTO fundamental_indicators (end_date, ann_date, ts_code, roe) + VALUES (?, ?, ?, ?) + """, + [ + ("20251231", "20260430", f"{index:06d}.SZ", 10.0) + for index in range(100) + ], + ) + + health = database.factor_health_summary("20260722") + self.assertTrue(health["market"]) + self.assertTrue(health["auction"]) + self.assertTrue(health["valuation"]) + self.assertTrue(health["fundamental"]) + self.assertTrue(health["dividend_history"]) + self.assertEqual(health["valuation_rows"], 1) + self.assertEqual(health["fundamental_rows"], 100) + self.assertEqual(health["dividend_years"], 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_frontend_contract.py b/tests/test_frontend_contract.py index b447882..bfd4862 100644 --- a/tests/test_frontend_contract.py +++ b/tests/test_frontend_contract.py @@ -41,9 +41,14 @@ class FrontendContractTests(unittest.TestCase): def test_all_primary_views_have_navigation_entries(self): views = set(re.findall(r'id="([A-Za-z][A-Za-z0-9_-]*View|limitPool)" class="workspace-view', self.html)) + internal_views = set(re.findall( + r'
]*\bdata-internal-view\b', + self.html, + )) navigation = set(re.findall(r'data-view="([A-Za-z][A-Za-z0-9_-]*)"', self.html)) - self.assertEqual(views, navigation) - self.assertEqual(len(views), 16) + self.assertEqual(views - internal_views, navigation) + self.assertEqual(len(views - internal_views), 16) + self.assertEqual(internal_views, {"screenerTrackingView"}) def test_market_discovery_views_are_wired_end_to_end(self): for view_id in ("auctionView", "themeLibraryView", "popularityView"): @@ -63,19 +68,52 @@ class FrontendContractTests(unittest.TestCase): themes = self.html.index('data-view="themeLibraryView"') self.assertLess(rotation, auction) self.assertLess(auction, themes) - for dataset in ("focus", "onePrice", "watchlist", "all"): + for dataset in ("focus", "watchlist", "all", "onePrice"): self.assertIn(f'data-auction-dataset="{dataset}"', self.html) + dataset_positions = [self.html.index(f'data-auction-dataset="{dataset}"') for dataset in ("focus", "watchlist", "all", "onePrice")] + self.assertEqual(dataset_positions, sorted(dataset_positions)) for filter_name in ("all", "above", "matched", "below"): self.assertIn(f'data-auction-filter="{filter_name}"', self.html) self.assertNotIn('data-auction-filter="strong"', self.html) self.assertNotIn('data-auction-filter="limit"', self.html) self.assertIn('id="auctionThemeCarry"', self.html) self.assertIn('id="auctionAmountTrend"', self.html) - self.assertIn('id="auctionNewsTitle"', self.html) + self.assertNotIn('id="auctionNewsTitle"', self.html) self.assertIn('id="auctionWorkspaceTitle"', self.html) self.assertIn('id="auctionExpectationFilterbar"', self.html) - self.assertIn('class="auction-news-entry"', self.html) - self.assertIn('class="disabled-status">暂不可用', self.html) + self.assertIn('id="auctionExpectationControls"', self.html) + self.assertNotIn('id="auctionAboveCount"', self.html) + self.assertNotIn('id="auctionMatchedCount"', self.html) + self.assertNotIn('id="auctionBelowCount"', self.html) + self.assertNotIn('class="auction-news-entry"', self.html) + + def test_visual_renovation_keeps_required_product_controls(self): + for order in ("oldest", "latest"): + self.assertIn(f'data-rotation-order="{order}"', self.html) + self.assertIn('id="dragonProfilesButton"', self.html) + self.assertIn('id="sentimentHistoryBody"', self.html) + self.assertIn('id="sentimentPreviousPositive"', self.html) + self.assertIn('id="accountDropdown"', self.html) + self.assertIn('id="settingsButton"', self.html) + + def test_screener_uses_progressive_strategy_editor(self): + for step in ("regime", "strategy", "run", "result"): + self.assertIn(f'data-screener-step="{step}"', self.html) + + def test_screener_exposes_curated_and_quant_workspaces(self): + for mode in ("smart", "curated", "quant"): + self.assertIn(f'data-screener-mode="{mode}"', self.html) + self.assertIn(f'data-screener-panel="{mode}"', self.html) + for element_id in ( + "curatedStrategyList", "curatedRunButton", "quantFilterRows", + "quantScoreRows", "quantRunButton", "quantSaveButton", + ): + self.assertIn(f'id="{element_id}"', self.html) + self.assertIn('id="strategyDrawer" class="strategy-drawer"', self.html) + self.assertIn('id="openStrategyDrawerButton"', self.html) + self.assertIn('id="closeStrategyDrawerButton"', self.html) + self.assertIn('id="activeStrategyDescription"', self.html) + self.assertIn('openStrategyDrawer("editor")', self.script) def test_public_knowledge_editors_are_hidden_for_non_admins(self): self.assertIn('document.querySelector("#reasonForm").hidden = !isAdmin;', self.script) @@ -177,6 +215,21 @@ class FrontendContractTests(unittest.TestCase): self.assertIn('elements.tradeLogDialog.showModal()', self.script) self.assertIn('renderTradeLog();\n closeTradeLogDialog();', self.script) + def test_review_workspace_exposes_complete_watchlist_and_three_part_journal(self): + for label in ( + "今日涨幅", "5日涨幅", "竞价关注(分)", "跟踪备注", "添加自选", + "今日盘面一句话", "今日做对了什么 / 做错了什么", "明日策略", + ): + self.assertIn(label, self.html) + for element_id in ( + "watchlistDialog", "watchlistSearchInput", "watchlistRemark", + "journalSummary", "journalContent", "journalPlan", + ): + self.assertIn(f'id="{element_id}"', self.html) + self.assertIn('summary: document.querySelector("#journalSummary").value', self.script) + self.assertIn('return_5d', self.script) + self.assertIn('attention_score', self.script) + def test_heaven_interpretations_use_one_dialog_and_history_tabs(self): self.assertIn('id="heavenReadingDialog"', self.html) self.assertIn('data-heaven-reading-tab="current"', self.html) diff --git a/tests/test_market_insights.py b/tests/test_market_insights.py index eb0702f..9f94f96 100644 --- a/tests/test_market_insights.py +++ b/tests/test_market_insights.py @@ -8,6 +8,7 @@ from pathlib import Path from database import ReviewDatabase from market_insights import MarketInsightsService from screener import FACTOR_FIELDS, ScreenerEngine +from tushare_client import TushareError class FakeMarketClient: @@ -166,6 +167,46 @@ class MarketInsightsTests(unittest.TestCase): self.assertEqual(hot["summary"]["dual_count"], 1) self.assertEqual(hot["combined"][0]["name"], "平安银行") + def test_feature_pages_use_local_data_when_trade_context_is_offline(self): + class OfflineClient: + def resolve_trade_context(self, requested: str): + raise TushareError("offline") + + def query(self, api_name, params=None, fields=""): + raise TushareError("offline") + + self.database.save_snapshot( + "20260723", "test", {"meta": {"trade_date": "2026-07-23"}} + ) + self.database.save_snapshot( + "20260724", "test", {"meta": {"trade_date": "2026-07-24"}} + ) + self.database.upsert_stock_master([ + {"ts_code": "000001.SZ", "name": "平安银行", "industry": "银行", "market": "主板", "list_date": "19910403"} + ]) + self.database.upsert_auction_factors([ + {"ts_code": "000001.SZ", "trade_date": "20260724", "price": 10.5, "pre_close": 10, "amount": 5_000_000, "vol": 20_000, "turnover_rate": 0.12, "volume_ratio": 1.8} + ]) + self.database.save_data_snapshot( + "theme_library_v1", "20260724", "market", + {"meta": {"trade_date": "2026-07-24"}, "summary": {"theme_count": 1}, "items": [{"code": "885001.TI", "name": "人工智能", "member_count": 2}]}, + ) + self.database.save_data_snapshot( + "popularity_v1", "20260724", "market", + {"meta": {"trade_date": "2026-07-24"}, "summary": {"ths_count": 1, "dc_count": 0, "dual_count": 0}, "combined": [{"name": "平安银行"}], "ths": [], "dc": []}, + ) + service = MarketInsightsService(self.database, OfflineClient(), self.service._now_provider) + + auction = service.auction_center("20260725") + self.assertEqual(auction["meta"]["trade_date"], "2026-07-24") + self.assertEqual(auction["summary"]["stock_count"], 1) + themes = service.theme_library("20260725") + self.assertTrue(themes["meta"]["cached"]) + self.assertEqual(themes["items"][0]["name"], "人工智能") + popularity = service.popularity("20260725") + self.assertTrue(popularity["meta"]["cached"]) + self.assertEqual(popularity["combined"][0]["name"], "平安银行") + class AuctionScreenerFactorTests(unittest.TestCase): def test_auction_fields_are_available_to_formula_and_factor_rows(self): diff --git a/tests/test_strategy_tracking.py b/tests/test_strategy_tracking.py index d151691..56acf37 100644 --- a/tests/test_strategy_tracking.py +++ b/tests/test_strategy_tracking.py @@ -99,6 +99,37 @@ class StrategyTrackingTests(unittest.TestCase): self.assertEqual(metrics["max_gain"], 10.0) self.assertEqual(metrics["max_drawdown"], -5.0) + def test_candidate_is_added_manually_and_can_be_removed_by_owner(self): + run_id = self.database.save_screener_run( + self.owner["id"], + "20260711", + "repair", + "手动跟踪策略", + {}, + { + "meta": {}, + "candidates": [{ + "ts_code": "600000.SH", + "code": "600000", + "name": "浦发银行", + "sector": "银行", + "price": 12.5, + }], + }, + ) + result = self.service.add_candidate(self.owner["id"], run_id, "600000") + self.assertEqual(result["added"], 1) + tracks = self.database.list_strategy_tracks(self.owner["id"]) + self.assertEqual(len(tracks), 1) + self.assertEqual(tracks[0]["code"], "600000") + self.assertEqual(self.database.list_strategy_tracks(self.other["id"]), []) + with self.assertRaises(ValueError): + self.service.add_candidate(self.other["id"], run_id, "600000") + + removed = self.service.remove_candidate(self.owner["id"], tracks[0]["id"]) + self.assertTrue(removed["deleted"]) + self.assertEqual(removed["tracking"]["batches"], []) + if __name__ == "__main__": unittest.main()