feat: redesign workspaces and stabilize screeners
This commit is contained in:
@@ -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 经用户验收后开始
|
||||
@@ -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"
|
||||
|
||||
+371
-32
@@ -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]]]:
|
||||
|
||||
+139
-62
@@ -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)
|
||||
|
||||
+484
-12
@@ -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])
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1878
-297
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
}
|
||||
+782
-460
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2507
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
+1405
-29
File diff suppressed because it is too large
Load Diff
@@ -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"))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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'<section id="([A-Za-z][A-Za-z0-9_-]*View)" class="workspace-view[^"]*"[^>]*\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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user