feat: integrate iFinD data and refine intelligent workspaces

This commit is contained in:
leefer
2026-07-28 16:38:56 +08:00
parent 6adeb54458
commit f4b2d7152a
22 changed files with 6481 additions and 389 deletions
+4
View File
@@ -5,6 +5,10 @@ APP_ENCRYPTION_KEY=
# the system settings; all accounts use the same backend market snapshot.
TUSHARE_TOKEN=your_tushare_token_here
# Optional iFinD HTTP credential. The backend exchanges it for a short-lived
# access token and never exposes either token to browsers.
IFIND_REFRESH_TOKEN=your_ifind_refresh_token_here
# Initial platform member models (OpenAI-compatible). After first launch these
# are encrypted into system settings and used only by admins and active members.
LLM_PRIMARY_BASE_URL=https://api.openai.com/v1
+7
View File
@@ -54,3 +54,10 @@
- 阶段 17:已完成并通过用户验收
- 阶段 18:已完成,等待用户验收
- 阶段 19:阶段 18 经用户验收后开始
## 阶段 19 补充验收项
- 按用户指定,在后续工作序列第 7 步(第 19 阶段)统一治理原生弹窗生命周期:禁止多弹窗重叠;打开、加载失败与关闭状态必须可恢复;所有弹窗保留可见关闭入口并支持 Escape;回归“空白长弹窗无法关闭、只能刷新恢复”的历史问题。
- 统一当前并存的多套历史设计令牌与页面局部规范:以全站设计规范为唯一基线,合并重复令牌,移除废弃、重复及页面内硬编码的颜色规则,避免日间与夜间主题各自出现局部失配。
- 建立搜索框、分段筛选、元数据标签、信息卡、表格与模态弹窗的统一组件契约;逐页清理绕过共享契约的局部实现,并对桌面/移动、日间/夜间四种组合执行视觉回归。
- 问天页面当前滚动条消失与内容高度不足问题不在夜间模式补丁中临时叠加规则,随问天重塑统一修正页面高度、滚动容器和三阶段布局。
+229
View File
@@ -8,9 +8,12 @@ import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timedelta
from threading import Lock
from typing import Any, ClassVar
from ifind_client import IfindError, IfindHttpClient
class ChartDataError(RuntimeError):
pass
@@ -30,6 +33,201 @@ INDEX_SECIDS = {
}
class MarketChartClient:
"""Prefer iFinD for display charts and retain Eastmoney as a last resort."""
def __init__(self, ifind: IfindHttpClient, fallback: "EastmoneyChartClient") -> None:
self.ifind = ifind
self.fallback = fallback
def stock_intraday(self, code: str) -> dict[str, Any]:
normalized = str(code or "").strip()
if not re.fullmatch(r"\d{6}", normalized):
raise ChartDataError("Invalid stock code")
ifind_code = _stock_market_code(normalized)
try:
return self._ifind_intraday(ifind_code, "stock", normalized)
except (IfindError, ChartDataError):
return self.fallback.stock_intraday(normalized)
def stock_daily(self, code: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
normalized = str(code or "").strip()
if not re.fullmatch(r"\d{6}", normalized):
raise ChartDataError("Invalid stock code")
return self._ifind_daily(_stock_market_code(normalized), end_date, limit)
def index_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
normalized = str(identifier or "").strip().upper()
if normalized not in INDEX_SECIDS:
raise ChartDataError("Unsupported index")
return self._ifind_daily(normalized, end_date, limit)
def board_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
normalized = str(identifier or "").strip().upper()
if not normalized:
raise ChartDataError("Invalid board code")
return self._ifind_daily(normalized, end_date, limit)
def index_intraday(self, identifier: str) -> dict[str, Any]:
normalized = str(identifier or "").strip().upper()
if normalized not in INDEX_SECIDS:
raise ChartDataError("Unsupported index")
try:
return self._ifind_intraday(normalized, "index", normalized)
except (IfindError, ChartDataError):
return self.fallback.index_intraday(normalized)
def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]:
normalized = str(identifier or "").strip().upper()
try:
return self._ifind_intraday(normalized, "board", normalized, name)
except (IfindError, ChartDataError):
return self.fallback.board_intraday(normalized, name)
def _ifind_intraday(
self,
ifind_code: str,
entity_type: str,
identifier: str,
name: str = "",
) -> dict[str, Any]:
if not self.ifind.configured:
raise ChartDataError("iFinD is not configured")
now = datetime.now().astimezone()
rows: list[dict[str, Any]] = []
for offset in range(0, 8):
candidate = now.date() - timedelta(days=offset)
if candidate.weekday() >= 5:
continue
display_date = candidate.isoformat()
rows = self.ifind.intraday(
ifind_code,
f"{display_date} 09:30:00",
f"{display_date} 15:00:00",
cache_ttl=20 if offset == 0 else 6 * 60 * 60,
)
if rows:
break
points = [point for row in rows if (point := _ifind_point(row))]
if not points:
raise ChartDataError("No iFinD intraday chart data returned")
latest_date = points[-1]["date"]
points = [point for point in points if point["date"] == latest_date]
previous_close = self._previous_close(ifind_code, latest_date, points[0]["open"])
return {
"entity_type": entity_type,
"identifier": identifier,
"name": name,
"code": identifier,
"trade_date": latest_date,
"previous_close": previous_close,
"points": points,
"source": "ifind",
}
def _ifind_daily(
self, ifind_code: str, end_date: str, limit: int
) -> list[dict[str, Any]]:
if not self.ifind.configured:
raise ChartDataError("iFinD is not configured")
compact_end = str(end_date or "").replace("-", "")
if not re.fullmatch(r"\d{8}", compact_end):
raise ChartDataError("Invalid chart end date")
end = datetime.strptime(compact_end, "%Y%m%d")
start = (end - timedelta(days=max(190, limit * 3))).strftime("%Y%m%d")
try:
rows = self.ifind.history(
ifind_code,
["open", "high", "low", "close", "volume", "amount"],
start,
compact_end,
cache_ttl=300,
)
except IfindError as exc:
raise ChartDataError("No iFinD daily chart data returned") from exc
normalized = []
for row in rows:
stamp = str(row.get("time") or "").strip()
trade_date = stamp[:10]
close = _number(row.get("close"))
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", trade_date) or close <= 0:
continue
normalized.append(
{
"trade_date": trade_date,
"open": _number(row.get("open")),
"high": _number(row.get("high")),
"low": _number(row.get("low")),
"close": close,
"volume": _number(row.get("volume")),
"amount_billion": _number(row.get("amount")) / 100_000_000,
}
)
normalized.sort(key=lambda row: row["trade_date"])
for index, row in enumerate(normalized):
previous = normalized[index - 1]["close"] if index > 0 else 0
row["change"] = round((row["close"] / previous - 1) * 100, 4) if previous else 0.0
today = datetime.now().astimezone().strftime("%Y%m%d")
if compact_end == today:
try:
quote_rows = self.ifind.real_time(
ifind_code,
["open", "high", "low", "latest", "preClose", "volume", "amount"],
cache_ttl=10,
)
quote = quote_rows[0] if quote_rows else {}
latest = _number(quote.get("latest"))
previous = _number(quote.get("preClose"))
if latest > 0:
realtime = {
"trade_date": end.strftime("%Y-%m-%d"),
"open": _number(quote.get("open")) or latest,
"high": _number(quote.get("high")) or latest,
"low": _number(quote.get("low")) or latest,
"close": latest,
"change": round((latest / previous - 1) * 100, 4) if previous else 0.0,
"volume": _number(quote.get("volume")),
"amount_billion": _number(quote.get("amount")) / 100_000_000,
"realtime": True,
}
if normalized and normalized[-1]["trade_date"] == realtime["trade_date"]:
normalized[-1] = realtime
else:
normalized.append(realtime)
except IfindError:
pass
if not normalized:
raise ChartDataError("No iFinD daily chart data returned")
return normalized[-max(20, min(180, int(limit))):]
def _previous_close(self, code: str, trade_date: str, fallback: float) -> float:
today = datetime.now().astimezone().date().isoformat()
if trade_date == today:
try:
quote = self.ifind.real_time(code, ["preClose"], cache_ttl=20)
value = _number((quote[0] if quote else {}).get("preClose"))
if value > 0:
return value
except IfindError:
pass
end = datetime.strptime(trade_date, "%Y-%m-%d")
try:
rows = self.ifind.history(
code,
["close"],
(end - timedelta(days=12)).strftime("%Y%m%d"),
end.strftime("%Y%m%d"),
cache_ttl=6 * 60 * 60,
)
closes = [_number(row.get("close")) for row in rows if _number(row.get("close")) > 0]
if len(closes) >= 2:
return closes[-2]
except IfindError:
pass
return fallback
@dataclass
class EastmoneyChartClient:
"""Isolated display-only minute chart source.
@@ -225,6 +423,37 @@ def _parse_trend(raw: Any) -> dict[str, Any] | None:
}
def _ifind_point(row: dict[str, Any]) -> dict[str, Any] | None:
stamp = str(row.get("time") or "").strip()
if " " not in stamp:
return None
trade_date, trade_time = stamp.split(" ", 1)
close = _number(row.get("close"))
if close <= 0:
return None
return {
"date": trade_date,
"time": trade_time[:5],
"open": _number(row.get("open")),
"close": close,
"high": _number(row.get("high")),
"low": _number(row.get("low")),
"volume": _number(row.get("volume")),
"amount": _number(row.get("amount")),
"average": _number(row.get("avgPrice")),
}
def _stock_market_code(code: str) -> str:
if code.startswith(("4", "8", "9")):
suffix = "BJ"
elif code.startswith("6"):
suffix = "SH"
else:
suffix = "SZ"
return f"{code}.{suffix}"
def _number(value: Any) -> float:
try:
return float(value or 0)
+101
View File
@@ -319,6 +319,21 @@ class ReviewDatabase:
CREATE INDEX IF NOT EXISTS idx_mentor_preferences_user_order
ON mentor_preferences(user_id, pinned DESC, sort_order, mentor_id);
CREATE TABLE IF NOT EXISTS wencai_saved_queries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
query TEXT NOT NULL,
search_type TEXT NOT NULL DEFAULT 'stock',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, query, search_type),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_wencai_saved_queries_user
ON wencai_saved_queries(user_id, updated_at DESC, id DESC);
CREATE TABLE IF NOT EXISTS strategy_tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
@@ -1730,7 +1745,10 @@ class ReviewDatabase:
result.setdefault("meta", {}).update(
{
"run_id": int(row["id"]),
"trade_date": str(row["trade_date"] or ""),
"regime": str(row["regime"] or ""),
"mode": str(row["mode"] or "smart"),
"strategy_name": str(row["strategy_name"] or ""),
"created_at": row["created_at"],
}
)
@@ -1780,6 +1798,39 @@ class ReviewDatabase:
results[mode] = payload
return results
def latest_screener_context_runs(
self, user_id: int, trade_date: str, limit: int = 60,
) -> list[dict[str, Any]]:
safe_limit = max(1, min(120, int(limit)))
with self.connect() as connection:
rows = connection.execute(
"""
WITH ranked AS (
SELECT id, trade_date, regime, mode, strategy_name, result, created_at,
ROW_NUMBER() OVER (
PARTITION BY
mode,
CASE WHEN mode = 'smart' THEN regime ELSE '' END,
CASE WHEN mode IN ('smart', 'curated') THEN strategy_name ELSE '' END
ORDER BY id DESC
) AS context_rank
FROM screener_runs
WHERE user_id = ? AND trade_date <= ?
)
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
FROM ranked
WHERE context_rank = 1
ORDER BY id DESC
LIMIT ?
""",
(int(user_id), trade_date, safe_limit),
).fetchall()
return [
payload
for row in rows
if (payload := self._screener_run_payload(row)) is not None
]
def get_screener_run(self, user_id: int, run_id: int) -> dict[str, Any] | None:
with self.connect() as connection:
row = connection.execute(
@@ -1902,6 +1953,56 @@ class ReviewDatabase:
values,
)
def list_wencai_saved_queries(
self, user_id: int, limit: int = 30
) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT id, title, query, search_type, created_at, updated_at
FROM wencai_saved_queries
WHERE user_id = ?
ORDER BY updated_at DESC, id DESC LIMIT ?
""",
(int(user_id), max(1, min(100, int(limit)))),
).fetchall()
return [dict(row) for row in rows]
def save_wencai_query(
self, user_id: int, title: str, query: str, search_type: str = "stock"
) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
connection.execute(
"""
INSERT INTO wencai_saved_queries
(user_id, title, query, search_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, query, search_type) DO UPDATE SET
title = excluded.title,
updated_at = excluded.updated_at
""",
(int(user_id), title, query, search_type, now, now),
)
row = connection.execute(
"""
SELECT id FROM wencai_saved_queries
WHERE user_id = ? AND query = ? AND search_type = ?
""",
(int(user_id), query, search_type),
).fetchone()
if not row:
raise ValueError("问财条件保存失败。")
return int(row["id"])
def delete_wencai_saved_query(self, user_id: int, query_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM wencai_saved_queries WHERE id = ? AND user_id = ?",
(int(query_id), int(user_id)),
)
return cursor.rowcount > 0
def save_strategy_tracks(
self,
user_id: int,
+385
View File
@@ -0,0 +1,385 @@
from __future__ import annotations
import copy
import json
import threading
import time
import urllib.error
import urllib.request
from datetime import datetime, timedelta
from typing import Any
class IfindError(RuntimeError):
pass
class IfindHttpClient:
BASE_URL = "https://quantapi.51ifind.com/api/v1"
AUTH_ENDPOINT = "get_access_token"
AUTH_ERROR_CODES = {-1302, -1303, -1304, -4302, -4303}
def __init__(
self,
refresh_token: str = "",
access_token: str = "",
timeout: int = 15,
) -> None:
self.timeout = max(3, int(timeout))
self._refresh_token = str(refresh_token or "").strip()
self._access_token = str(access_token or "").strip()
self._access_expires_at: datetime | None = None
self._token_lock = threading.Lock()
self._cache_lock = threading.Lock()
self._cache: dict[str, dict[str, Any]] = {}
@property
def configured(self) -> bool:
return bool(self._refresh_token or self._access_token)
def set_credentials(self, refresh_token: str, access_token: str = "") -> None:
refresh_token = str(refresh_token or "").strip()
access_token = str(access_token or "").strip()
with self._token_lock:
refresh_changed = refresh_token != self._refresh_token
self._refresh_token = refresh_token
if access_token or refresh_changed:
self._access_token = access_token
self._access_expires_at = None
if refresh_changed:
with self._cache_lock:
self._cache.clear()
def status(self) -> dict[str, Any]:
return {
"configured": self.configured,
"access_ready": bool(self._access_token),
"access_expires_at": (
self._access_expires_at.isoformat(timespec="seconds")
if self._access_expires_at
else ""
),
}
def test_connection(self) -> dict[str, Any]:
payload = self.real_time(
"000001.SH",
["open", "high", "low", "latest", "preClose"],
cache_ttl=0,
)
return {
"ok": bool(payload),
"sample_time": str(payload[0].get("time") or "") if payload else "",
}
def real_time(
self,
codes: str | list[str],
indicators: list[str],
cache_ttl: int = 10,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"real_time_quotation",
{"codes": code_text, "indicators": ",".join(indicators)},
cache_key=f"rq:{code_text}:{','.join(indicators)}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def history(
self,
codes: str | list[str],
indicators: list[str],
start_date: str,
end_date: str,
cache_ttl: int = 300,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"cmd_history_quotation",
{
"codes": code_text,
"indicators": ",".join(indicators),
"startdate": self._display_date(start_date),
"enddate": self._display_date(end_date),
"functionpara": {"CPS": "forward1", "Fill": "Omit"},
},
cache_key=f"hq:{code_text}:{start_date}:{end_date}:{','.join(indicators)}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def intraday(
self,
code: str,
start_time: str,
end_time: str,
cache_ttl: int = 20,
) -> list[dict[str, Any]]:
indicators = ["open", "high", "low", "close", "volume", "amount", "avgPrice"]
payload = self._request(
"high_frequency",
{
"codes": self._codes(code),
"indicators": ",".join(indicators),
"starttime": start_time,
"endtime": end_time,
"functionpara": {
"CPS": "forward1",
"Fill": "Previous",
"Timeformat": "LocalTime",
"Interval": "1",
"Limitstart": "09:30:00",
"Limitend": "15:00:00",
},
},
cache_key=f"hf:{code}:{start_time}:{end_time}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def snapshots(
self,
codes: str | list[str],
indicators: list[str],
start_time: str,
end_time: str,
cache_ttl: int = 8,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"snap_shot",
{
"codes": code_text,
"indicators": ",".join(indicators),
"starttime": start_time,
"endtime": end_time,
},
cache_key=f"ss:{code_text}:{start_time}:{end_time}:{','.join(indicators)}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def wencai(self, query: str, search_type: str = "stock", cache_ttl: int = 300) -> list[dict[str, Any]]:
normalized = " ".join(str(query or "").split())
if not normalized:
raise IfindError("问财查询不能为空。")
payload = self._request(
"smart_stock_picking",
{"searchstring": normalized, "searchtype": search_type},
cache_key=f"wc:{search_type}:{normalized}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def report_query(
self,
codes: str | list[str],
begin_date: str,
end_date: str,
cache_ttl: int = 300,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"report_query",
{
"codes": code_text,
"beginrDate": self._display_date(begin_date),
"endrDate": self._display_date(end_date),
"outputpara": (
"reportDate:Y,thscode:Y,secName:Y,ctime:Y,"
"reportTitle:Y,pdfURL:Y,seq:Y"
),
},
cache_key=f"report:{code_text}:{begin_date}:{end_date}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def _request(
self,
endpoint: str,
body: dict[str, Any],
cache_key: str = "",
cache_ttl: int = 0,
) -> dict[str, Any]:
if not self.configured:
raise IfindError("iFinD 尚未配置。")
if cache_key and cache_ttl > 0:
cached = self._cached(cache_key, cache_ttl)
if cached is not None:
return cached
payload = self._post(endpoint, body, self._ensure_access_token())
if self._is_auth_error(payload) and self._refresh_token:
self._invalidate_access_token()
payload = self._post(endpoint, body, self._ensure_access_token(force=True))
self._validate_payload(payload)
if cache_key and cache_ttl > 0:
with self._cache_lock:
self._cache[cache_key] = {
"created_at": time.time(),
"payload": copy.deepcopy(payload),
}
return payload
def _ensure_access_token(self, force: bool = False) -> str:
with self._token_lock:
now = datetime.now().astimezone().replace(tzinfo=None)
token_valid = bool(self._access_token) and (
self._access_expires_at is None
or self._access_expires_at > now + timedelta(minutes=2)
)
if token_valid and not force:
return self._access_token
if not self._refresh_token:
if self._access_token:
return self._access_token
raise IfindError("iFinD Refresh Token 尚未配置。")
payload = self._post(self.AUTH_ENDPOINT, {}, "", self._refresh_token)
self._validate_payload(payload)
data = payload.get("data") or {}
token = str(data.get("access_token") or "").strip()
if not token:
raise IfindError("iFinD 未返回 Access Token。")
expires_at = self._parse_datetime(data.get("expired_time"))
self._access_token = token
self._access_expires_at = expires_at
return token
def _post(
self,
endpoint: str,
body: dict[str, Any],
access_token: str,
refresh_token: str = "",
) -> dict[str, Any]:
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "XiaobaiReviewWeb/1.0",
"ifindlang": "cn",
}
if access_token:
headers["access_token"] = access_token
if refresh_token:
headers["refresh_token"] = refresh_token
request = urllib.request.Request(
f"{self.BASE_URL}/{endpoint}",
data=json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = ""
try:
detail_payload = json.loads(exc.read().decode("utf-8", errors="replace"))
detail = str(detail_payload.get("errmsg") or detail_payload.get("message") or "")
except (json.JSONDecodeError, OSError):
pass
raise IfindError(f"iFinD HTTP {exc.code}{f'{detail[:160]}' if detail else ''}") from exc
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
raise IfindError("iFinD 数据请求失败。") from exc
if not isinstance(payload, dict):
raise IfindError("iFinD 返回格式不正确。")
return payload
def _cached(self, key: str, ttl: int) -> dict[str, Any] | None:
with self._cache_lock:
cached = self._cache.get(key)
if not cached:
return None
if time.time() - float(cached.get("created_at") or 0) > ttl:
self._cache.pop(key, None)
return None
return copy.deepcopy(cached["payload"])
def _invalidate_access_token(self) -> None:
with self._token_lock:
self._access_token = ""
self._access_expires_at = None
@classmethod
def _validate_payload(cls, payload: dict[str, Any]) -> None:
try:
error_code = int(payload.get("errorcode") or 0)
except (TypeError, ValueError):
error_code = -1
if error_code != 0:
message = str(payload.get("errmsg") or "未知错误")
raise IfindError(f"iFinD 返回错误:{message[:200]}")
@classmethod
def _is_auth_error(cls, payload: dict[str, Any]) -> bool:
try:
error_code = int(payload.get("errorcode") or 0)
except (TypeError, ValueError):
error_code = 0
message = str(payload.get("errmsg") or "").casefold()
return error_code in cls.AUTH_ERROR_CODES or "token" in message or "鉴权" in message
@staticmethod
def _table_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
tables = payload.get("tables") or []
if isinstance(tables, dict):
tables = [tables]
rows: list[dict[str, Any]] = []
for block in tables if isinstance(tables, list) else []:
if not isinstance(block, dict):
continue
table = block.get("table") or {}
if not isinstance(table, dict):
continue
times = block.get("time") or []
codes = block.get("thscode") or block.get("thscodes") or []
if isinstance(codes, str):
codes = [codes]
lengths = [len(value) for value in table.values() if isinstance(value, list)]
row_count = max(lengths or [len(times) if isinstance(times, list) else 0, 1 if table else 0])
for index in range(row_count):
row: dict[str, Any] = {}
if isinstance(times, list) and index < len(times):
row["time"] = times[index]
if codes:
row["thscode"] = codes[index] if index < len(codes) else codes[0]
for field, values in table.items():
if isinstance(values, list):
row[field] = values[index] if index < len(values) else None
elif index == 0:
row[field] = values
rows.append(row)
return rows
@staticmethod
def _codes(codes: str | list[str]) -> str:
if isinstance(codes, list):
values = [str(code or "").strip().upper() for code in codes]
else:
values = [part.strip().upper() for part in str(codes or "").split(",")]
values = [value for value in values if value]
if not values:
raise IfindError("iFinD 证券代码不能为空。")
if len(values) > 100:
raise IfindError("iFinD 单次证券代码过多。")
return ",".join(values)
@staticmethod
def _display_date(value: str) -> str:
compact = str(value or "").replace("-", "")
if len(compact) != 8 or not compact.isdigit():
raise IfindError("iFinD 日期格式不正确。")
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
@staticmethod
def _parse_datetime(value: Any) -> datetime | None:
text = str(value or "").strip()
if not text:
return None
try:
return datetime.fromisoformat(text)
except ValueError:
return None
+117 -7
View File
@@ -7,6 +7,7 @@ from statistics import median
from typing import Any, Callable
from database import ReviewDatabase
from ifind_client import IfindError, IfindHttpClient
from tushare_client import TushareClient, TushareError
@@ -36,10 +37,12 @@ class MarketInsightsService:
database: ReviewDatabase,
client: TushareClient,
now_provider: Callable[[], datetime] | None = None,
ifind: IfindHttpClient | None = None,
) -> None:
self.database = database
self.client = client
self._now_provider = now_provider or (lambda: datetime.now(CHINA_TIMEZONE))
self.ifind = ifind
def _trade_context(self, requested_date: str) -> tuple[str, str]:
"""Resolve trading dates without making cached feature pages depend on Tushare uptime."""
@@ -607,6 +610,108 @@ class MarketInsightsService:
personalized["watchlist_missing_count"] = missing
return personalized
def _dynamic_auction_rows(
self,
trade_date: str,
baseline_date: str,
user_id: int,
) -> list[dict[str, Any]]:
if not self.ifind or not self.ifind.configured:
return []
master = self._stock_master()
placeholders = [
{
"code": str(item.get("code") or ts_code.split(".")[0]),
"ts_code": ts_code,
"name": str(item.get("name") or "--"),
"sector": str(item.get("industry") or "其他"),
}
for ts_code, item in master.items()
]
candidates, _, _ = self._auction_candidates(placeholders, baseline_date)
selected_codes = {
str(item.get("ts_code") or "")
for item in candidates
if item.get("ts_code")
}
if user_id:
watched = {str(item.get("code") or "") for item in self.database.list_watchlist(user_id)}
selected_codes.update(
ts_code for ts_code in master if ts_code.split(".")[0] in watched
)
selected_codes.discard("")
if not selected_codes:
return []
display_date = _display_date(trade_date)
now = self._now_provider()
if now.tzinfo is None:
now = now.replace(tzinfo=CHINA_TIMEZONE)
else:
now = now.astimezone(CHINA_TIMEZONE)
end_time = min(now.time().replace(tzinfo=None), dt_time(9, 25))
end_stamp = f"{display_date} {end_time.strftime('%H:%M:%S')}"
start_stamp = f"{display_date} 09:15:00"
snapshot_rows: list[dict[str, Any]] = []
ordered_codes = sorted(selected_codes)
try:
for index in range(0, len(ordered_codes), 80):
snapshot_rows.extend(
self.ifind.snapshots(
ordered_codes[index:index + 80],
[
"latest", "volume", "amount", "preClose",
"bid1", "bidSize1", "ask1", "askSize1",
],
start_stamp,
end_stamp,
cache_ttl=8,
)
)
except IfindError:
return []
latest: dict[str, dict[str, Any]] = {}
for row in snapshot_rows:
ts_code = str(row.get("thscode") or "")
if ts_code and _number(row.get("latest")) > 0:
latest[ts_code] = row
prior_factors = {
str(item.get("ts_code") or ""): item
for item in self.database.auction_factors_for_date(baseline_date)
}
normalized = []
for ts_code, row in latest.items():
price = _number(row.get("latest"))
pre_close = _number(row.get("preClose"))
volume = _number(row.get("volume"))
bid_size = _number(row.get("bidSize1"))
ask_size = _number(row.get("askSize1"))
if volume <= 0 and bid_size > 0 and ask_size > 0:
volume = min(bid_size, ask_size)
amount = _number(row.get("amount"))
if amount <= 0 and price > 0 and volume > 0:
amount = price * volume
prior_volume = _number((prior_factors.get(ts_code) or {}).get("vol"))
normalized.append(
{
"ts_code": ts_code,
"trade_date": trade_date,
"vol": volume,
"price": price,
"amount": amount,
"pre_close": pre_close,
"turnover_rate": 0,
"volume_ratio": volume / prior_volume if prior_volume > 0 else 0,
"float_share": 0,
"bid_size1": bid_size,
"ask_size1": ask_size,
"snapshot_time": str(row.get("time") or ""),
"dynamic": True,
}
)
return normalized
def auction_center(
self,
requested_date: str,
@@ -616,10 +721,11 @@ class MarketInsightsService:
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
dynamic = phase == "observing" and bool(self.ifind and self.ifind.configured)
data_date = previous_date if phase == "pending" or (phase == "observing" and not dynamic) else trade_date
carried_forward = data_date != trade_date
cache_key = data_date
if not force:
if not force and not dynamic:
cached = self.database.get_data_snapshot("auction_center_v5", cache_key)
if cached:
result = copy.deepcopy(cached)
@@ -634,10 +740,13 @@ class MarketInsightsService:
}
return self._with_auction_watchlist(result, data_date, user_id)
try:
rows = self.client.query("stk_auction", {"trade_date": data_date})
except TushareError:
rows = self.database.auction_factors_for_date(data_date)
if dynamic:
rows = self._dynamic_auction_rows(data_date, previous_date, user_id)
else:
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:
return {
"meta": {
@@ -798,7 +907,8 @@ class MarketInsightsService:
"one_price_rows": one_price_rows,
"rows": candidates,
}
self.database.save_data_snapshot("auction_center_v5", cache_key, "market", result)
if not dynamic:
self.database.save_data_snapshot("auction_center_v5", cache_key, "market", result)
return self._with_auction_watchlist(result, data_date, user_id)
def _theme_directory(self) -> list[dict[str, Any]]:
+622 -55
View File
@@ -19,7 +19,7 @@ from urllib.parse import parse_qs, unquote, urlparse
from alert_service import AlertService
from assistant_agent import ReviewAssistantError, stream_review_assistant
from api_access import required_role
from chart_data_provider import ChartDataError, EastmoneyChartClient
from chart_data_provider import ChartDataError, EastmoneyChartClient, MarketChartClient
from app_config import (
DATA_DIR,
MENTOR_SKILLS_DIR,
@@ -50,6 +50,7 @@ from heaven_engine import (
build_personal_field,
hexagram_from_lines,
)
from ifind_client import IfindError, IfindHttpClient
from llm_strategy import LLMCompilerError, compile_strategy_with_llm, test_llm_connection
from mentor_agent import MentorAgentError, MentorSkillRegistry, stream_with_mentor
from market_insights import MarketInsightsService
@@ -76,6 +77,8 @@ from tushare_client import TushareClient, TushareError, _sector_coverage_issue
LEGACY_SECRET_KEYS = {
"TUSHARE_TOKEN",
"IFIND_REFRESH_TOKEN",
"IFIND_ACCESS_TOKEN",
"LLM_API_KEY",
"LLM_BASE_URL",
"LLM_MODEL",
@@ -104,12 +107,51 @@ THS_SEARCH_TYPES = {
"N": ("theme", "概念题材"),
}
MENTOR_DATA_PROFILES = {
"emotion": {
"kobe92-perspective", "niepanchongsheng-perspective",
"chaojiyangjia-perspective", "tuixuechaogu-perspective",
"chenxiaoqun-perspective", "zhiyechaoshou-perspective",
},
"first_board": {
"beijingchaojia-perspective", "chuangshiji-perspective",
"xuxiang-perspective", "foshanwuyingjiao-perspective",
},
"leader": {
"zhaolaoge-perspective", "fangxinxia-perspective",
"xiaoe-perspective", "sunge-perspective", "liuyizhonglu-perspective",
},
"trend": {
"zhangdetao-perspective", "zhangmengzhu-perspective",
"zuoshouxinyi-perspective",
},
"low_absorption": {
"qiaobangzhu-perspective", "asking-perspective",
"longfeihu-perspective", "ruihexian-perspective",
},
"macro": {"shuipi-perspective"},
}
MENTOR_INDEX_UNIVERSE = (
("000001.SH", "上证指数"), ("399001.SZ", "深证成指"),
("399006.SZ", "创业板指"), ("000016.SH", "上证50"),
("000300.SH", "沪深300"), ("000905.SH", "中证500"),
("000852.SH", "中证1000"), ("932000.CSI", "中证2000"),
)
MENTOR_ETF_UNIVERSE = (
("510050.SH", "上证50ETF"), ("510300.SH", "沪深300ETF"),
("510500.SH", "中证500ETF"), ("512100.SH", "中证1000ETF"),
)
class DashboardService:
def __init__(self) -> None:
load_local_env()
environment_credentials = {
"tushare_token": os.environ.get("TUSHARE_TOKEN", "").strip(),
"ifind_refresh_token": os.environ.get("IFIND_REFRESH_TOKEN", "").strip(),
"ifind_access_token": os.environ.get("IFIND_ACCESS_TOKEN", "").strip(),
"platform_llm_primary_api_key": os.environ.get(
"LLM_PRIMARY_API_KEY", os.environ.get("LLM_API_KEY", "")
).strip(),
@@ -133,15 +175,20 @@ class DashboardService:
self.sync_lock = threading.Lock()
self.auth_lock = threading.Lock()
self.system_lock = threading.Lock()
self._ifind_event_lock = threading.Lock()
self._request_context = threading.local()
self._system_credentials = self._load_system_credentials(environment_credentials)
self.ifind = IfindHttpClient(
str(self._system_credentials.get("ifind_refresh_token") or ""),
str(self._system_credentials.get("ifind_access_token") or ""),
)
self.screener = ScreenerEngine(self.database)
self.strategy_tracking = StrategyTrackingService(self.database)
self.alert_service = AlertService(self.database)
self.trade_journal = TradeJournalService(self.database)
self.mentor_skills = MentorSkillRegistry(MENTOR_SKILLS_DIR, PRIVATE_MENTOR_SKILLS_DIR)
self.realtime_aggregator = WebRealtimeAggregator()
self.chart_data = EastmoneyChartClient()
self.chart_data = MarketChartClient(self.ifind, EastmoneyChartClient())
self.screener.ensure_builtin_strategies()
self._background_stop = threading.Event()
self._background_thread = threading.Thread(
@@ -162,6 +209,8 @@ class DashboardService:
first_personal = self.vault.decrypt_json(first_encrypted) if first_encrypted else {}
defaults = {
"tushare_token": environment.get("tushare_token") or first_personal.get("tushare_token") or "",
"ifind_refresh_token": environment.get("ifind_refresh_token") or "",
"ifind_access_token": environment.get("ifind_access_token") or "",
"platform_llm_primary_api_key": environment.get("platform_llm_primary_api_key") or first_personal.get("llm_primary_api_key") or "",
"platform_llm_primary_base_url": environment.get("platform_llm_primary_base_url") or first_personal.get("llm_primary_base_url") or "https://api.openai.com/v1",
"platform_llm_primary_model": environment.get("platform_llm_primary_model") or first_personal.get("llm_primary_model") or "",
@@ -208,6 +257,11 @@ class DashboardService:
with self.system_lock:
self.database.save_system_setting("credentials", self.vault.encrypt_json(credentials))
self._system_credentials = dict(credentials)
if hasattr(self, "ifind"):
self.ifind.set_credentials(
str(credentials.get("ifind_refresh_token") or ""),
str(credentials.get("ifind_access_token") or ""),
)
@property
def configured(self) -> bool:
@@ -514,6 +568,7 @@ class DashboardService:
return {
"data": {
"configured": self.configured,
"ifind": self.ifind.status(),
"background_refresh_enabled": bool(
self._system_credentials.get("background_refresh_enabled", True)
),
@@ -538,6 +593,16 @@ class DashboardService:
token = str(payload.get("tushare_token") or current.get("tushare_token") or "").strip()
if token and not TOKEN_PATTERN.fullmatch(token):
raise ValueError("Tushare Token 格式不正确。")
ifind_refresh_token = str(
payload.get("ifind_refresh_token")
or current.get("ifind_refresh_token")
or ""
).strip()
if ifind_refresh_token and (
len(ifind_refresh_token) > 2048
or any(character.isspace() for character in ifind_refresh_token)
):
raise ValueError("iFinD Refresh Token 格式不正确。")
existing_models = {
str(item.get("id") or ""): item
for item in current.get("llm_models") or []
@@ -600,6 +665,7 @@ class DashboardService:
current.update(
{
"tushare_token": token,
"ifind_refresh_token": ifind_refresh_token,
"llm_models": models,
"primary_model_id": primary_model_id,
"fallback_model_id": fallback_model_id,
@@ -868,11 +934,51 @@ class DashboardService:
snapshot.setdefault("meta", {}).update(
{"realtime": False, "market_status": "closed"}
)
snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date)
if not self._dashboard_sentiment_ready(snapshot):
snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date)
snapshot.setdefault("meta", {})["requested_date"] = self._display_compact_date(normalized_date)
return self._apply_reason_overrides(self._with_storage(snapshot, cached=True))
resolved = self.database.get_data_snapshot(
"dashboard_request_v1", normalized_date
)
if resolved and str((resolved.get("meta") or {}).get("source") or "") != "demo":
resolved = copy.deepcopy(resolved)
resolved.setdefault("meta", {})["requested_date"] = self._display_compact_date(
normalized_date
)
return self._apply_reason_overrides(
self._with_storage(resolved, cached=True)
)
if datetime.strptime(normalized_date, "%Y%m%d").weekday() >= 5:
previous = self.database.get_latest_real_snapshot(normalized_date)
if previous:
carried = self._carry_dashboard(
previous,
normalized_date,
"非交易日沿用最近交易日收盘行情",
)
self.database.save_data_snapshot(
"dashboard_request_v1", normalized_date, "sqlite", carried
)
return self._apply_reason_overrides(
self._with_storage(carried, cached=True)
)
return self.sync_dashboard(normalized_date)
@staticmethod
def _dashboard_sentiment_ready(dashboard: dict[str, Any]) -> bool:
overview = dashboard.get("overview") or {}
return all(
key in overview
for key in (
"sentiment_score",
"sentiment_label",
"sentiment_phase",
"sentiment_direction",
"sentiment_components",
)
)
@staticmethod
def _display_compact_date(compact: str) -> str:
return f"{compact[:4]}-{compact[4:6]}-{compact[6:8]}"
@@ -945,6 +1051,17 @@ class DashboardService:
str(dashboard.get("meta", {}).get("trade_date") or normalized_date)
)
self.database.save_snapshot(actual_date, source, dashboard)
if actual_date != normalized_date:
dashboard.setdefault("meta", {}).update(
{
"carried_forward": True,
"realtime": False,
"market_status": "closed",
}
)
self.database.save_data_snapshot(
"dashboard_request_v1", normalized_date, source, dashboard
)
self.database.finish_sync(
sync_id,
"success",
@@ -1073,7 +1190,11 @@ class DashboardService:
def _market_insights(self) -> MarketInsightsService:
if not self.configured:
raise ValueError("行情数据尚未配置。")
return MarketInsightsService(self.database, TushareClient(self.token))
return MarketInsightsService(
self.database,
TushareClient(self.token),
ifind=self.ifind,
)
def auction_center(self, trade_date: str, force: bool = False) -> dict[str, Any]:
return self._market_insights().auction_center(
@@ -1089,6 +1210,30 @@ class DashboardService:
def popularity(self, trade_date: str, force: bool = False) -> dict[str, Any]:
return self._market_insights().popularity(normalize_date(trade_date), force)
@staticmethod
def _ifind_field(row: dict[str, Any], tokens: tuple[str, ...]) -> Any:
for key, value in row.items():
label = str(key or "")
if any(token.casefold() == label.casefold() for token in tokens):
return value
for key, value in row.items():
label = str(key or "")
if any(token in label for token in tokens):
return value
return None
@classmethod
def _ifind_row_code(cls, row: dict[str, Any]) -> str:
value = cls._ifind_field(row, ("股票代码", "证券代码", "代码", "thscode"))
match = re.search(r"(?<!\d)(\d{6})(?!\d)", str(value or ""))
if match:
return match.group(1)
for value in row.values():
match = re.search(r"(?<!\d)(\d{6})\.(?:SH|SZ|BJ)(?![A-Z])", str(value or ""), re.I)
if match:
return match.group(1)
return ""
def screener_setup(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
regime = self.screener.detect_regime(normalized_date)
@@ -1150,6 +1295,9 @@ class DashboardService:
"latest_results": self.database.latest_screener_runs(
self.current_user_id, normalized_date
),
"recent_results": self.database.latest_screener_context_runs(
self.current_user_id, normalized_date
),
# Kept during the client transition for compatibility with older frontends.
"latest_result": self.database.latest_screener_run(
self.current_user_id, normalized_date, "smart"
@@ -1578,7 +1726,7 @@ class DashboardService:
skill = self.mentor_skills.get_skill(
mentor_id, include_private=self.membership()["is_admin"]
)
context = self._build_mentor_context(trade_date, question)
context = self._build_mentor_context(trade_date, question, skill)
source = self.ensure_llm_access("mentor")
profiles = []
@@ -2990,7 +3138,9 @@ class DashboardService:
history.append({"role": item["role"], "content": content})
return history
def _build_mentor_context(self, trade_date: str, question: str) -> dict[str, Any]:
def _build_mentor_context(
self, trade_date: str, question: str, skill: Any | None = None
) -> dict[str, Any]:
dashboard = self.get_dashboard(trade_date)
data_trade_date = normalize_date(
str(dashboard.get("meta", {}).get("trade_date") or trade_date)
@@ -3009,6 +3159,10 @@ class DashboardService:
if code in codes or (len(name) >= 2 and name in question):
if not any(item.get("code") == code for item in matched_rows):
matched_rows.append(row)
for row in matched_rows:
code = str(row.get("code") or "")
if code and code not in codes:
codes.append(code)
stock_details = []
for code in codes[:2]:
try:
@@ -3023,8 +3177,17 @@ class DashboardService:
except Exception as exc:
stock_details.append({"code": code, "error": str(exc)})
skill_id = str(getattr(skill, "skill_id", "") or "")
profile = next(
(
profile_name
for profile_name, skill_ids in MENTOR_DATA_PROFILES.items()
if skill_id in skill_ids
),
"balanced",
)
dragon_tiger = None
if codes or any(keyword in question for keyword in ("龙虎榜", "席位", "机构", "游资")):
if any(keyword in question for keyword in ("龙虎榜", "席位", "机构", "游资")):
try:
dragon_payload = self.get_dragon_tiger(data_trade_date)
rows = list(dragon_payload.get("rows") or [])
@@ -3042,45 +3205,189 @@ class DashboardService:
except Exception as exc:
dragon_tiger = {"error": str(exc)}
return {
context: dict[str, Any] = {
"data_trade_date": data_trade_date,
"source": dashboard.get("meta", {}).get("source"),
"notice": dashboard.get("meta", {}).get("notice") or "",
"data_profile": profile,
"overview": dashboard.get("overview") or {},
"market_regime": regime,
"recent_market_history": self.database.snapshot_summaries(data_trade_date, 10),
"limit_ladder": dashboard.get("ladders") or [],
"limit_performance": dashboard.get("limit_performance") or [],
"hot_sectors": (dashboard.get("sectors") or [])[:20],
"sector_rotation": (dashboard.get("sector_rotation") or [])[:20],
"limit_up_stocks": sorted(
limits,
key=lambda row: (
float(row.get("streak") or 0),
float(row.get("amount_billion") or 0),
),
reverse=True,
)[:30],
"broken_stocks": sorted(
broken,
key=lambda row: float(row.get("amount_billion") or 0),
reverse=True,
)[:20],
"limit_down_stocks": sorted(
down_limits,
key=lambda row: float(row.get("amount_billion") or 0),
reverse=True,
)[:25],
"yesterday_limit_performance": sorted(
yesterday_limits,
key=lambda row: float(row.get("change") or 0),
reverse=True,
)[:25],
"question_matched_stocks": matched_rows[:10],
"stock_details": stock_details,
"dragon_tiger": dragon_tiger,
}
ordered_limits = sorted(
limits,
key=lambda row: (
float(row.get("streak") or 0),
float(row.get("amount_billion") or 0),
),
reverse=True,
)
if profile in {"emotion", "balanced"}:
context.update(
{
"limit_ladder": dashboard.get("ladders") or [],
"limit_performance": dashboard.get("limit_performance") or [],
"hot_sectors": (dashboard.get("sectors") or [])[:15],
"sector_rotation": (dashboard.get("sector_rotation") or [])[:15],
"limit_up_stocks": ordered_limits[:30],
"broken_stocks": sorted(
broken,
key=lambda row: float(row.get("amount_billion") or 0),
reverse=True,
)[:20],
"limit_down_stocks": down_limits[:20],
"yesterday_limit_performance": sorted(
yesterday_limits,
key=lambda row: float(row.get("change") or 0),
reverse=True,
)[:20],
}
)
elif profile == "first_board":
context.update(
{
"first_board_environment": {
"seal_rate": (dashboard.get("overview") or {}).get("seal_rate"),
"broken_count": len(broken),
"first_boards": [row for row in ordered_limits if int(row.get("streak") or 1) == 1][:35],
"broken_stocks": sorted(
broken,
key=lambda row: float(row.get("amount_billion") or 0),
reverse=True,
)[:30],
},
"hot_sectors": (dashboard.get("sectors") or [])[:12],
}
)
elif profile == "leader":
context.update(
{
"limit_ladder": dashboard.get("ladders") or [],
"multi_board_leaders": [
row for row in ordered_limits if int(row.get("streak") or 0) >= 2
][:25],
"hot_sectors": (dashboard.get("sectors") or [])[:12],
"sector_rotation": (dashboard.get("sector_rotation") or [])[:12],
}
)
try:
popularity = self.popularity(data_trade_date)
context["popularity_core"] = {
"consensus": [
row for row in (popularity.get("combined") or [])
if row.get("dual_source")
][:10],
"ths": (popularity.get("ths") or [])[:10],
"eastmoney": (popularity.get("dc") or [])[:10],
}
except Exception:
context["popularity_core"] = {"unavailable": True}
elif profile == "trend":
context.update(
{
"index_momentum": self._mentor_market_matrix(
data_trade_date, MENTOR_INDEX_UNIVERSE
),
"sector_rotation": (dashboard.get("sector_rotation") or [])[:20],
"hot_sectors": (dashboard.get("sectors") or [])[:20],
"market_breadth": {
key: (dashboard.get("overview") or {}).get(key)
for key in ("up_count", "down_count", "flat_count", "amount_billion")
},
}
)
elif profile == "low_absorption":
context.update(
{
"yesterday_limit_performance": sorted(
yesterday_limits,
key=lambda row: float(row.get("change") or 0),
reverse=True,
)[:35],
"broken_stocks": broken[:20],
"hot_sectors": (dashboard.get("sectors") or [])[:12],
}
)
elif profile == "macro":
context.update(
{
"broad_indexes": self._mentor_market_matrix(
data_trade_date, MENTOR_INDEX_UNIVERSE
),
"core_etfs": self._mentor_market_matrix(
data_trade_date, MENTOR_ETF_UNIVERSE
),
"market_style": {
"amount_billion": (dashboard.get("overview") or {}).get("amount_billion"),
"breadth": {
"up": (dashboard.get("overview") or {}).get("up_count"),
"down": (dashboard.get("overview") or {}).get("down_count"),
},
"top_sectors": (dashboard.get("sectors") or [])[:15],
},
"unavailable_data": [
"政策原文与隔夜资讯尚未接入",
"汇率、利率和商品宏观序列当前不可用",
],
}
)
if dragon_tiger is not None:
context["dragon_tiger"] = dragon_tiger
return context
def _mentor_market_matrix(
self, trade_date: str, universe: tuple[tuple[str, str], ...]
) -> list[dict[str, Any]]:
ifind = getattr(self, "ifind", None)
if not ifind or not ifind.configured:
return []
end = datetime.strptime(trade_date, "%Y%m%d")
start = (end - timedelta(days=45)).strftime("%Y%m%d")
names = {code: name for code, name in universe}
try:
rows = ifind.history(
list(names), ["close", "volume", "amount"], start, trade_date, cache_ttl=600
)
except IfindError:
return []
grouped: dict[str, list[dict[str, Any]]] = {}
for row in rows:
code = str(row.get("thscode") or "").upper()
if code in names:
grouped.setdefault(code, []).append(row)
result = []
for code, name in universe:
series = sorted(grouped.get(code, []), key=lambda row: str(row.get("time") or ""))
closes = []
for row in series:
try:
close = float(row.get("close") or 0)
except (TypeError, ValueError):
continue
if close > 0:
closes.append(close)
if not closes:
continue
def period_return(days: int) -> float | None:
if len(closes) <= days or closes[-days - 1] <= 0:
return None
return round((closes[-1] / closes[-days - 1] - 1) * 100, 2)
previous = closes[-2] if len(closes) > 1 else 0
result.append(
{
"code": code,
"name": name,
"close": round(closes[-1], 3),
"change": round((closes[-1] / previous - 1) * 100, 2) if previous else None,
"return_5d": period_return(5),
"return_10d": period_return(10),
"return_20d": period_return(20),
"latest_amount": series[-1].get("amount") if series else None,
}
)
return result
def run_screener(self, payload: dict[str, Any]) -> dict[str, Any]:
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
regime = str(payload.get("regime") or "")
@@ -3118,6 +3425,65 @@ class DashboardService:
)
return result
def get_hot_money_profiles(self, force: bool = False) -> dict[str, Any]:
cache_kind = "hot_money_profiles_v1"
cache_key = "directory"
cached = self.database.get_data_snapshot(cache_kind, cache_key)
if cached and not force:
cached["meta"] = {**cached.get("meta", {}), "cached": True}
return cached
if self.configured:
try:
payload = TushareClient(self.token).hot_money_profiles()
except TushareError:
if cached:
cached["meta"] = {
**cached.get("meta", {}),
"cached": True,
"stale": True,
"notice": "名录暂未完成更新,当前展示最近一次收录结果。",
}
return cached
return {
"meta": {
"source": "unavailable",
"status": "unavailable",
"schema_version": 1,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "游资名录暂不可用,请稍后重试。",
},
"summary": {
"profile_count": 0,
"described_count": 0,
"organization_count": 0,
},
"profiles": [],
}
payload["meta"]["cached"] = False
if payload.get("meta", {}).get("status") == "success":
self.database.save_data_snapshot(cache_kind, cache_key, "tushare", payload)
return payload
if cached:
cached["meta"] = {**cached.get("meta", {}), "cached": True}
return cached
return {
"meta": {
"source": "unavailable",
"status": "unavailable",
"schema_version": 1,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "游资名录暂不可用,请联系管理员检查行情配置。",
},
"summary": {
"profile_count": 0,
"described_count": 0,
"organization_count": 0,
},
"profiles": [],
}
def get_dragon_tiger(self, trade_date: str, force: bool = False) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
cache_kind = "hot_money_detail_v3"
@@ -3400,6 +3766,12 @@ class DashboardService:
}
for row in rows[-90:]
]
try:
chart_series = self.chart_data.board_daily(identifier, resolved_date, 90)
if chart_series:
series = chart_series
except (AttributeError, ChartDataError):
pass
latest = series[-1] if series else {}
snapshot_is_current = str(snapshot.get("trade_date") or "").replace("-", "") == resolved_date
change = float(
@@ -3407,6 +3779,8 @@ class DashboardService:
if snapshot_is_current and snapshot.get("change") is not None
else latest.get("change") or 0
)
if latest.get("realtime"):
change = float(latest.get("change") or 0)
turnover_rate = float(
snapshot.get("turnover_rate")
if snapshot_is_current and snapshot.get("turnover_rate") is not None
@@ -3492,6 +3866,21 @@ class DashboardService:
}
for row in rows[-90:]
]
try:
chart_series = self.chart_data.index_daily(str(basic["id"]), resolved_date, 90)
if chart_series:
series = chart_series
except (AttributeError, ChartDataError):
pass
latest = series[-1] if series else {}
latest_close = float(latest.get("close") or current.get("close") or 0)
latest_change = float(latest.get("change") or current.get("pct_chg") or 0)
def series_return(days: int) -> float:
if len(series) <= days:
return 0.0
previous = float(series[-days - 1].get("close") or 0)
return (latest_close / previous - 1) * 100 if previous > 0 else 0.0
return {
"meta": {
"trade_date": self._display_compact_date(str(current.get("trade_date") or resolved_date)),
@@ -3500,14 +3889,14 @@ class DashboardService:
"entity": {
**basic,
"type_label": SEARCH_TYPE_LABELS["index"],
"value": float(current.get("close") or 0),
"change": float(current.get("pct_chg") or 0),
"value": latest_close,
"change": latest_change,
},
"series": series,
"metrics": [
{"label": "涨跌幅", "value": round(float(current.get("pct_chg") or 0), 2), "unit": "%", "tone": "change"},
{"label": "近5日", "value": round(float(current.get("return_5d") or 0), 2), "unit": "%", "tone": "change"},
{"label": "近20日", "value": round(float(current.get("return_20d") or 0), 2), "unit": "%", "tone": "change"},
{"label": "涨跌幅", "value": round(latest_change, 2), "unit": "%", "tone": "change"},
{"label": "近5日", "value": round(series_return(5), 2), "unit": "%", "tone": "change"},
{"label": "近20日", "value": round(series_return(20), 2), "unit": "%", "tone": "change"},
{"label": "成交额", "value": round(float(current.get("amount_billion") or 0), 2), "unit": "亿"},
],
}
@@ -3584,22 +3973,30 @@ class DashboardService:
self, payload: dict[str, Any], code: str, requested_date: str
) -> dict[str, Any]:
result = copy.deepcopy(payload)
try:
result["prices"] = self.chart_data.stock_daily(code, requested_date, 90)
result["meta"] = {**(result.get("meta") or {}), "chart_source": "market_chart"}
except (AttributeError, ChartDataError):
pass
actual_date = self._stock_detail_bar_date(result)
if actual_date:
result["meta"] = {
**(result.get("meta") or {}),
"trade_date": f"{actual_date[:4]}-{actual_date[4:6]}-{actual_date[6:]}",
}
if self.configured:
client = TushareClient(self.token)
now = datetime.now().astimezone()
today = now.strftime("%Y%m%d")
should_merge = (
requested_date == today
and actual_date < today
and now.time().replace(tzinfo=None) >= dt_time(9, 15)
)
if should_merge:
now = datetime.now().astimezone()
today = now.strftime("%Y%m%d")
should_merge = (
requested_date == today
and actual_date <= today
and now.time().replace(tzinfo=None) >= dt_time(9, 15)
)
if should_merge:
quote = self._ifind_realtime_stock_quote(code)
if quote:
self._merge_realtime_stock_detail(result, quote, requested_date)
elif self.configured and actual_date < today:
client = TushareClient(self.token)
try:
resolved_date, _ = client.resolve_trade_context(requested_date)
if resolved_date == today:
@@ -3609,6 +4006,42 @@ class DashboardService:
pass
return self._enrich_stock_detail(result)
def _ifind_realtime_stock_quote(self, code: str) -> dict[str, Any] | None:
ifind = getattr(self, "ifind", None)
if not ifind or not ifind.configured:
return None
try:
rows = ifind.real_time(
tushare_code(code),
[
"open", "high", "low", "latest", "preClose",
"volume", "amount", "turnoverRatio",
],
cache_ttl=10,
)
except IfindError:
return None
row = rows[0] if rows else {}
price = float(row.get("latest") or 0)
previous_close = float(row.get("preClose") or 0)
if price <= 0:
return None
change = (price / previous_close - 1) * 100 if previous_close > 0 else 0.0
stock = self._stock_identity(code, date.today().strftime("%Y%m%d"))
return {
"name": stock[0],
"sector": stock[1],
"price": price,
"open": float(row.get("open") or price),
"high": float(row.get("high") or price),
"low": float(row.get("low") or price),
"change": round(change, 4),
"volume": float(row.get("volume") or 0),
"volume_unit": "lots",
"amount_billion": float(row.get("amount") or 0) / 100_000_000,
"turnover_rate": float(row.get("turnoverRatio") or 0),
}
@staticmethod
def _merge_realtime_stock_detail(
payload: dict[str, Any], quote: dict[str, Any], trade_date: str
@@ -3621,7 +4054,7 @@ class DashboardService:
"low": quote["low"],
"close": quote["price"],
"change": quote["change"],
"volume": quote["volume"] / 100,
"volume": quote["volume"] if quote.get("volume_unit") == "lots" else quote["volume"] / 100,
"amount_billion": quote["amount_billion"],
"realtime": True,
}
@@ -3654,7 +4087,9 @@ class DashboardService:
self, code: str, trade_date: str, force: bool = False
) -> dict[str, Any]:
code = validate_stock_code(code)
detail = self.get_stock_detail(code, trade_date, force)
# Hover previews deliberately follow the latest market day, independent
# from the review date selected by the page.
detail = self.get_stock_detail(code, date.today().strftime("%Y%m%d"), force)
detail_meta = detail.get("meta") or {}
resolved_date = str(detail_meta.get("trade_date") or trade_date)
intraday_points: list[dict[str, Any]] = []
@@ -3758,6 +4193,11 @@ class DashboardService:
def _apply_reason_overrides(self, dashboard: dict[str, Any]) -> dict[str, Any]:
trade_date = str(dashboard.get("meta", {}).get("trade_date", "")).replace("-", "")
enrichment = self.database.get_data_snapshot("ifind_event_enrichment_v1", trade_date)
if enrichment:
self._merge_ifind_event_enrichment(dashboard, enrichment)
else:
self._schedule_ifind_event_enrichment(trade_date)
overrides = self.database.reason_overrides(trade_date)
if not overrides:
return dashboard
@@ -3768,6 +4208,122 @@ class DashboardService:
row["reason_source"] = "manual"
return dashboard
def _schedule_ifind_event_enrichment(self, trade_date: str) -> None:
ifind = getattr(self, "ifind", None)
if not ifind or not ifind.configured or not re.fullmatch(r"\d{8}", trade_date):
return
now = datetime.now().astimezone()
if trade_date == now.strftime("%Y%m%d") and now.time().replace(tzinfo=None) < dt_time(15, 0):
return
thread = threading.Thread(
target=self._refresh_ifind_event_enrichment,
args=(trade_date,),
name=f"ifind-event-{trade_date}",
daemon=True,
)
thread.start()
def _refresh_ifind_event_enrichment(self, trade_date: str) -> None:
if not self._ifind_event_lock.acquire(blocking=False):
return
try:
if self.database.get_data_snapshot("ifind_event_enrichment_v1", trade_date):
return
ifind = getattr(self, "ifind", None)
if not ifind or not ifind.configured:
return
current = datetime.strptime(trade_date, "%Y%m%d")
display_date = f"{current.year}{current.month}{current.day}"
requests = {
"limits": (
f"{display_date}涨停股票,股票代码、股票简称、涨停原因、"
"首次涨停时间、最终涨停时间、开板次数"
),
"broken": (
f"{display_date}曾涨停但收盘未涨停的股票,股票代码、股票简称、"
"涨停原因、首次涨停时间、开板次数"
),
"down_limits": (
f"{display_date}跌停股票,股票代码、股票简称、跌停原因"
),
}
result: dict[str, Any] = {
"trade_date": trade_date,
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"limits": {}, "broken": {}, "down_limits": {}, "partial": False,
}
for kind, query in requests.items():
try:
rows = ifind.wencai(query, "stock", cache_ttl=900)
except IfindError:
result["partial"] = True
continue
for raw in rows:
code = self._ifind_row_code(raw)
if not code:
continue
reason_tokens = (
("跌停原因", "风险线索", "原因")
if kind == "down_limits"
else ("涨停原因类别", "涨停原因", "触板逻辑", "原因")
)
reason = str(self._ifind_field(raw, reason_tokens) or "").strip()
first_time = self._normalize_ifind_event_time(
self._ifind_field(raw, ("首次涨停时间", "首次触板时间", "首次封板时间"))
)
last_time = self._normalize_ifind_event_time(
self._ifind_field(raw, ("最终涨停时间", "最后涨停时间", "最后封板时间"))
)
open_times = self._ifind_field(raw, ("开板次数", "打开涨停次数"))
try:
open_count = max(0, int(float(open_times))) if open_times not in (None, "") else None
except (TypeError, ValueError):
open_count = None
result[kind][code] = {
"reason": reason,
"first_time": first_time,
"last_time": last_time,
"open_times": open_count,
}
if any(result[kind] for kind in ("limits", "broken", "down_limits")):
self.database.save_data_snapshot(
"ifind_event_enrichment_v1", trade_date, "ifind", result
)
finally:
self._ifind_event_lock.release()
@staticmethod
def _normalize_ifind_event_time(value: Any) -> str:
text = str(value or "").strip()
match = re.search(r"(?:^|\s)(\d{1,2}:\d{2}(?::\d{2})?)(?:$|\s)", text)
if not match:
match = re.search(r"(?<!\d)(\d{6})(?!\d)", text)
if match:
compact = match.group(1)
return f"{compact[:2]}:{compact[2:4]}:{compact[4:]}"
return ""
parts = match.group(1).split(":")
return ":".join(part.zfill(2) for part in parts)
@staticmethod
def _merge_ifind_event_enrichment(
dashboard: dict[str, Any], enrichment: dict[str, Any]
) -> None:
for kind in ("limits", "broken", "down_limits"):
records = enrichment.get(kind) or {}
for row in dashboard.get(kind) or []:
event = records.get(str(row.get("code") or "")) or {}
reason = str(event.get("reason") or "").strip()
if reason:
row["reason"] = reason
row["reason_source"] = "market_event"
if event.get("first_time"):
row["first_time"] = event["first_time"]
if event.get("last_time"):
row["last_time"] = event["last_time"]
if event.get("open_times") is not None:
row["open_times"] = event["open_times"]
def _apply_seat_aliases(self, payload: dict[str, Any]) -> dict[str, Any]:
aliases = self.database.list_seat_aliases()
result = dict(payload)
@@ -4094,6 +4650,17 @@ class RequestHandler(BaseHTTPRequestHandler):
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/dragon-tiger/profiles":
query = parse_qs(parsed.query)
try:
self.send_json(
SERVICE.get_hot_money_profiles(
query.get("force", ["0"])[0] == "1"
)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/search":
query = parse_qs(parsed.query)
search_query = query.get("q", [""])[0]
+619 -168
View File
File diff suppressed because it is too large Load Diff
+131 -3
View File
@@ -29,6 +29,10 @@
--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));
--sentiment-history-max-height:510px;
--sentiment-chart-compact-height:clamp(180px,20dvh,250px);
--sentiment-chart-compact-canvas-height:calc(var(--sentiment-chart-compact-height) - 18px);
--sentiment-history-min-height:220px;
--primary-share:1.45fr;
--secondary-share:.75fr;
--mobile-nav-height:58px;
@@ -40,6 +44,7 @@
--space-4:4px;
--font-aux:10.5px;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
@@ -689,10 +694,28 @@ tbody tr.clickable{cursor:pointer}
}
#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;
/* Keep the stage range label and its current value on one left-aligned axis. */
#sentimentCycleView .sentiment-stage-guide-head > span:nth-child(3),
#sentimentCycleView .sentiment-stage-guide-grid article .stage-range{
text-align:left;
}
/* Sentiment history: numeric columns align right; categorical columns align centrally. */
#sentimentCycleView .sentiment-history-table tbody td.number{
text-align:right;
font-variant-numeric:tabular-nums;
}
/* The history table owns its scrolling; the fixed status bar must not cover its final row. */
#sentimentCycleView .sentiment-history-frame{
max-height:var(--sentiment-history-max-height);
overflow:auto;
}
#sentimentCycleView .sentiment-history-table .sentiment-history-columns th:nth-child(3),
#sentimentCycleView .sentiment-history-table .sentiment-history-columns th:nth-child(4),
#sentimentCycleView .sentiment-history-table tbody td:nth-child(3),
#sentimentCycleView .sentiment-history-table tbody td:nth-child(4){
text-align:center;
}
/* Strategy cards select on direct click; the condition dialog stays viewport-centered. */
@@ -746,6 +769,54 @@ tbody tr.clickable{cursor:pointer}
: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="sentimentCycleView"],
[data-active-view="yesterdayView"]
) .app-main{
height:var(--workspace-height);
min-height:0;
display:flex;
flex-direction:column;
overflow:hidden;
}
body:is(
[data-active-view="sentimentCycleView"],
[data-active-view="yesterdayView"]
) .overview-strip{flex:0 0 auto}
#sentimentCycleView.active-view,
#yesterdayView.active-view{
min-height:0;
flex:1 1 auto;
display:flex;
flex-direction:column;
overflow:hidden;
}
#sentimentCycleView > :is(.sentiment-cycle-toolbar,#sentimentHistoryNotice,.sentiment-cycle-analysis,.sentiment-detail-toolbar),
#yesterdayView > .yesterday-page-head,
#yesterdayView .yesterday-result-summary{flex:0 0 auto}
#sentimentCycleView .sentiment-history-frame{
min-height:var(--sentiment-history-min-height);
max-height:none;
flex:1 1 auto;
}
#yesterdayView .yesterday-table-card{
min-height:0;
flex:1 1 auto;
display:flex;
flex-direction:column;
}
#yesterdayView .yesterday-table-scroll{
min-height:0;
max-height:none;
flex:1 1 auto;
}
body:is(
[data-active-view="auctionView"],
[data-active-view="themeLibraryView"],
@@ -845,7 +916,64 @@ tbody tr.clickable{cursor:pointer}
#mentorView .mentor-messages{min-height:0;overflow:auto}
}
@media (min-width:721px) and (max-height:1100px){
#sentimentCycleView .sentiment-chart-shell{height:var(--sentiment-chart-compact-height)}
#sentimentCycleView .sentiment-chart-shell canvas{height:var(--sentiment-chart-compact-canvas-height)}
#sentimentCycleView .sentiment-phase-block{gap:12px;padding:10px 12px}
#sentimentCycleView .sentiment-current-phase-badge{padding:8px 12px}
#sentimentCycleView .sentiment-phase-advice{margin-top:4px;padding:4px 8px;line-height:1.4}
#sentimentCycleView .sentiment-feedback-strip > span{padding:4px 10px}
#sentimentCycleView .sentiment-component-list{padding:4px 16px 8px}
#sentimentCycleView .sentiment-component-item{padding:4px 0}
#sentimentCycleView .sentiment-component-item small{display:none}
}
/* Full-page workspaces: at desktop sizes the page, rather than an inner card,
owns vertical scrolling. This keeps dense 1080p screens usable without
shrinking the primary content. */
@media (min-width:721px){
:root body:is(
[data-active-view="sentimentCycleView"],
[data-active-view="rotationView"],
[data-active-view="screenerView"]
) .app-main{
height:var(--workspace-height);
min-height:0;
display:block;
overflow-x:hidden;
overflow-y:auto;
}
:root #sentimentCycleView.active-view,
:root #rotationView.active-view,
:root #screenerView.active-view{
height:auto;
min-height:0;
display:block;
overflow:visible;
}
:root #sentimentCycleView .sentiment-history-frame,
:root #rotationView .rotation-history,
:root #rotationView .rotation-table-frame,
:root #screenerView .screener-result-frame{
max-height:none;
overflow:visible;
}
:root #rotationView .rotation-trajectory-card,
:root #rotationView .rotation-detail-card{
min-height:0;
margin-top:var(--card-gap);
display:block;
overflow:visible;
}
:root #rotationView .rotation-page-head{margin-bottom:0}
}
@media (max-width:720px), (max-width:1023px) and (max-height:600px){
:root{--sentiment-history-max-height:min(480px,calc(100dvh - 210px))}
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}
+723
View File
@@ -0,0 +1,723 @@
(function exposeHeavenLoading(global) {
"use strict";
// Theme palettes share the original animation geometry and timing.
const LOADING_PALETTES = {
dark: {
paper: "#05060d",
paperCenter: "#10142a",
paperMiddle: "#0b0e1e",
nodeText: "#f7e3b4",
ink: "#e6c37a",
inkBright: "#f7e3b4",
gold: "#e6c37a",
goldBright: "#f7e3b4",
cinnabar: "#d8564a",
dim: "rgba(216,205,180,0.55)",
particles: ["#e6c37a", "#d8564a", "#6d7fa8"],
},
light: {
paper: "#eef1f4",
paperCenter: "#fffefa",
paperMiddle: "#f4f2eb",
nodeText: "#493a20",
ink: "#8a641d",
inkBright: "#624612",
gold: "#946b1d",
goldBright: "#765315",
cinnabar: "#b94f46",
dim: "rgba(52,58,67,0.62)",
particles: ["#946b1d", "#b94f46", "#73859c"],
},
};
let PAPER;
let PAPER_CENTER;
let PAPER_MIDDLE;
let NODE_TEXT;
let INK;
let INK_BRIGHT;
let GOLD;
let GOLD_BRIGHT;
let CINNABAR;
let DIM;
let PARTICLE_COLORS;
const applyLoadingPalette = () => {
const theme = document.documentElement.dataset.theme === "light" ? "light" : "dark";
const palette = LOADING_PALETTES[theme];
PAPER = palette.paper;
PAPER_CENTER = palette.paperCenter;
PAPER_MIDDLE = palette.paperMiddle;
NODE_TEXT = palette.nodeText;
INK = palette.ink;
INK_BRIGHT = palette.inkBright;
GOLD = palette.gold;
GOLD_BRIGHT = palette.goldBright;
CINNABAR = palette.cinnabar;
DIM = palette.dim;
PARTICLE_COLORS = palette.particles;
return theme;
};
applyLoadingPalette();
const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",serif';
const ELEMENT_COLORS = {
: "#4f7a4a",
: "#b3483d",
: "#96702c",
: "#70685b",
: "#496d92",
};
const QI6 = [
{ name: "厥阴风木", element: "木" },
{ name: "少阴君火", element: "火" },
{ name: "少阳相火", element: "火" },
{ name: "太阴湿土", element: "土" },
{ name: "阳明燥金", element: "金" },
{ name: "太阳寒水", element: "水" },
];
const STEP_RANGES = ["大寒 — 春分", "春分 — 小满", "小满 — 大暑", "大暑 — 秋分", "秋分 — 小雪", "小雪 — 大寒"];
const TRIGRAMS = [
{ name: "乾", bits: [1, 1, 1], angle: -90 },
{ name: "兑", bits: [1, 1, 0], angle: -135 },
{ name: "离", bits: [1, 0, 1], angle: 180 },
{ name: "震", bits: [1, 0, 0], angle: 135 },
{ name: "巽", bits: [0, 1, 1], angle: -45 },
{ name: "坎", bits: [0, 1, 0], angle: 0 },
{ name: "艮", bits: [0, 0, 1], angle: 45 },
{ name: "坤", bits: [0, 0, 0], angle: 90 },
];
const SIXIANG = [
{ name: "太阳", bits: [1, 1], dx: 0, dy: -1 },
{ name: "少阴", bits: [1, 0], dx: 1, dy: 0 },
{ name: "太阴", bits: [0, 0], dx: 0, dy: 1 },
{ name: "少阳", bits: [0, 1], dx: -1, dy: 0 },
];
const HEXAGRAM_NAMES = [
"坤", "剥", "比", "观", "豫", "晋", "萃", "否", "谦", "艮", "蹇", "渐", "小过", "旅", "咸", "遁",
"师", "蒙", "坎", "涣", "解", "未济", "困", "讼", "升", "蛊", "井", "巽", "恒", "鼎", "大过", "姤",
"复", "颐", "屯", "益", "震", "噬嗑", "随", "无妄", "明夷", "贲", "既济", "家人", "丰", "革", "同人", "临",
"损", "节", "中孚", "归妹", "睽", "兑", "履", "泰", "大畜", "需", "小畜", "大壮", "大有", "夬", "乾",
];
const HEX_TOTAL = 12500;
const FORTUNE_TOTAL = 12800;
const HEX_STAGES = [
[0, 1800, "太 极", "无极而太极,动而生阳"],
[1800, 3300, "两 仪", "一阴一阳之谓道"],
[3300, 4700, "四 象", "阴阳消长,太少相生"],
[4700, 6800, "八 卦", "天地定位,山泽通气"],
[6800, 10800, "六 十 四 卦", "卦者挂也,悬物象以示人"],
[10800, HEX_TOTAL, "归 一", "万物负阴而抱阳,冲气以为和"],
];
const clamp01 = (value) => Math.max(0, Math.min(1, value));
const smooth = (start, end, value) => {
const progress = clamp01((value - start) / Math.max(1, end - start));
return progress * progress * (3 - 2 * progress);
};
const easeOut = (value) => 1 - Math.pow(1 - clamp01(value), 3);
const hexBits = (index) => Array.from({ length: 6 }, (_, bit) => (index >> (5 - bit)) & 1);
const point = (cx, cy, radius, degrees) => {
const radians = degrees * Math.PI / 180;
return [cx + Math.cos(radians) * radius, cy + Math.sin(radians) * radius];
};
class HeavenLoadingCanvas {
constructor(canvas) {
this.canvas = canvas;
this.context = canvas.getContext("2d");
this.width = 0;
this.height = 0;
this.dpr = 1;
this.scene = "hexagram";
this.data = {};
this.startedAt = 0;
this.frameId = 0;
this.running = false;
this.completingAt = 0;
this.completionResolve = null;
this.completionTimer = 0;
this.resizeObserver = new ResizeObserver(() => this.resize());
this.reducedMotion = global.matchMedia("(prefers-reduced-motion: reduce)").matches;
this.theme = document.documentElement.dataset.theme || "dark";
this.stars = this.createStars(this.reducedMotion ? 48 : 150);
}
createStars(count) {
let seed = 24681357;
const random = () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 4294967296;
};
return Array.from({ length: count }, () => ({
x: random(),
y: random(),
radius: 0.3 + random() * 1.3,
phase: random() * Math.PI * 2,
speed: 0.00015 + random() * 0.0004,
colorIndex: Math.floor(random() * PARTICLE_COLORS.length),
}));
}
start(scene, data = {}) {
this.theme = applyLoadingPalette();
const nextScene = scene === "fortune" ? "fortune" : "hexagram";
if (this.running && this.scene === nextScene) {
this.data = data;
return;
}
this.stop();
this.scene = nextScene;
this.data = data;
this.startedAt = performance.now();
this.running = true;
this.canvas.dataset.scene = this.scene;
this.canvas.dataset.running = "true";
this.canvas.dataset.looping = "true";
this.resizeObserver.observe(this.canvas);
this.resize();
if (this.reducedMotion) {
this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now());
} else {
this.frameId = requestAnimationFrame((now) => this.frame(now));
}
}
complete() {
if (!this.running || this.reducedMotion) {
this.stop();
return Promise.resolve();
}
if (this.completionResolve) return this.completionPromise;
this.completingAt = performance.now();
this.completionPromise = new Promise((resolve) => { this.completionResolve = resolve; });
this.completionTimer = global.setTimeout(() => this.stop(), 2200);
return this.completionPromise;
}
stop() {
if (this.frameId) cancelAnimationFrame(this.frameId);
this.frameId = 0;
this.running = false;
this.completingAt = 0;
if (this.completionTimer) global.clearTimeout(this.completionTimer);
this.completionTimer = 0;
this.resizeObserver.disconnect();
this.canvas.dataset.running = "false";
this.canvas.dataset.looping = "false";
if (this.completionResolve) this.completionResolve();
this.completionResolve = null;
this.completionPromise = null;
}
resize() {
const rect = this.canvas.getBoundingClientRect();
const width = Math.max(1, Math.round(rect.width));
const height = Math.max(1, Math.round(rect.height));
if (width === this.width && height === this.height) return;
this.width = width;
this.height = height;
this.dpr = Math.min(global.devicePixelRatio || 1, 2);
this.canvas.width = Math.round(width * this.dpr);
this.canvas.height = Math.round(height * this.dpr);
this.context.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
if (this.running && this.reducedMotion) {
this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now());
}
}
frame(now) {
if (!this.running) return;
if (this.completingAt) {
const duration = this.scene === "fortune" ? 1800 : 1700;
const progress = clamp01((now - this.completingAt) / duration);
this.drawCompletion(progress, now);
if (progress >= 1) {
this.stop();
return;
}
} else {
const total = this.scene === "fortune" ? FORTUNE_TOTAL : HEX_TOTAL;
const elapsed = Math.max(0, now - this.startedAt);
const timeline = elapsed % total;
this.canvas.dataset.cycle = String(Math.floor(elapsed / total));
this.draw(timeline, now);
}
this.frameId = requestAnimationFrame((time) => this.frame(time));
}
draw(time, now) {
if (this.width <= 1 || this.height <= 1) return;
this.drawBackground(now);
if (this.scene === "fortune") this.drawFortune(time, now);
else this.drawHexagram(time, now);
}
drawBackground(now) {
const currentTheme = document.documentElement.dataset.theme || "dark";
if (currentTheme !== this.theme) this.theme = applyLoadingPalette();
const { context: ctx, width, height } = this;
const cx = width / 2;
const cy = height * 0.4;
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.75);
gradient.addColorStop(0, PAPER_CENTER);
gradient.addColorStop(0.52, PAPER_MIDDLE);
gradient.addColorStop(1, PAPER);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
for (const star of this.stars) {
const twinkle = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(star.phase + now * 0.0012));
const alpha = twinkle * 0.5;
ctx.globalAlpha = alpha;
ctx.fillStyle = PARTICLE_COLORS[star.colorIndex];
const y = ((star.y + now * star.speed) % 1) * height;
ctx.fillRect(star.x * width, y, star.radius, star.radius);
}
ctx.globalAlpha = 1;
}
label(text, x, y, size, color = INK, alpha = 1, weight = "", maxWidth) {
if (!text || alpha <= 0) return;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.font = `${weight ? `${weight} ` : ""}${size}px ${SERIF}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
if (maxWidth) ctx.fillText(text, x, y, maxWidth);
else ctx.fillText(text, x, y);
ctx.restore();
}
node(x, y, radius, color, alpha = 1, glow = 0) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = glow;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
line(x1, y1, x2, y2, color, alpha = 1, width = 1) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.restore();
}
curvedArrow(x1, y1, x2, y2, mx, my, color, alpha) {
if (alpha <= 0) return;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(mx, my, x2, y2);
ctx.stroke();
const angle = Math.atan2(y2 - my, x2 - mx);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - 7 * Math.cos(angle - 0.42), y2 - 7 * Math.sin(angle - 0.42));
ctx.lineTo(x2 - 7 * Math.cos(angle + 0.42), y2 - 7 * Math.sin(angle + 0.42));
ctx.closePath();
ctx.fill();
ctx.restore();
}
drawYao(cx, cy, width, lineWidth, yang, alpha, glow = 0) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = INK;
ctx.shadowColor = GOLD;
ctx.shadowBlur = glow;
if (yang) {
ctx.fillRect(cx - width / 2, cy - lineWidth / 2, width, lineWidth);
} else {
const gap = width * 0.18;
ctx.fillRect(cx - width / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth);
ctx.fillRect(cx + gap / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth);
}
ctx.restore();
}
drawGua(cx, cy, width, lineWidth, bits, alpha, glow = 0) {
const gap = lineWidth * 1.7;
const top = cy - (bits.length - 1) * gap / 2;
bits.forEach((bit, index) => {
this.drawYao(cx, top + (bits.length - 1 - index) * gap, width, lineWidth, bit === 1, alpha, glow);
});
}
stageAlpha(time, start, end, fade = 300, hold = false) {
const enter = smooth(start, start + fade, time);
return hold ? enter : enter * (1 - smooth(end - fade, end, time));
}
fortuneStages() {
const sixQi = this.data.sixQi || {};
const pillar = this.data.yearPillar || "岁运";
const movement = this.data.movement || "中运合参";
const sitian = sixQi.sitian || "司天气候";
return [
[0, 2100, "五 运", "木火土金水,五运相袭,周而复始"],
[2100, 3900, "十 干 化 运", "甲己土 · 乙庚金 · 丙辛水 · 丁壬木 · 戊癸火"],
[3900, 5800, "十 二 支 化 气", "子午少阴 · 丑未太阴 · 寅申少阳 · 卯酉阳明 · 辰戌太阳 · 巳亥厥阴"],
[5800, 7900, "六 气 环 布", "风寒暑湿燥火,分主六步,以应岁时"],
[7900, 11000, "岁 运 合 参", `${pillar}年 · 中运${movement} · ${sitian}司天`],
[11000, FORTUNE_TOTAL, "归 一", "谨守病机,无失气宜"],
];
}
drawFooter(time, now, total, stages, scene) {
const { context: ctx, width, height } = this;
const stage = [...stages].reverse().find((item) => time >= item[0]) || stages[0];
const labelAlpha = smooth(stage[0], stage[0] + 300, time)
* (1 - smooth(stage[1] - 250, stage[1], time));
this.label(stage[2], width / 2, height - 108, 19, GOLD, 0.55 + 0.45 * labelAlpha, "600");
this.label(stage[3], width / 2, height - 84, 12.5, DIM, (0.4 + 0.4 * labelAlpha) * (scene === "fortune" ? 0.85 : 0.8), "", width - 32);
const baseSlotWidth = 34;
const baseSlotHeight = 5;
const baseSlotGap = 12;
const baseTotalWidth = baseSlotWidth * 6 + baseSlotGap * 5;
const fit = Math.min(1, (width - 28) / baseTotalWidth);
const slotWidth = baseSlotWidth * fit;
const slotHeight = baseSlotHeight * fit;
const slotGap = baseSlotGap * fit;
const totalWidth = slotWidth * 6 + slotGap * 5;
const filled = Math.min(6, Math.floor(time / (total / 6)));
for (let index = 0; index < 6; index += 1) {
const x = width / 2 - totalWidth / 2 + index * (slotWidth + slotGap);
const y = height - 56;
const color = scene === "fortune" ? ELEMENT_COLORS[QI6[index].element] : GOLD;
ctx.save();
ctx.globalAlpha = 0.16;
ctx.strokeStyle = GOLD;
ctx.lineWidth = 1;
ctx.strokeRect(x, y, slotWidth, slotHeight);
ctx.restore();
if (index < filled) {
ctx.save();
ctx.globalAlpha = 0.9;
ctx.fillStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = 8;
ctx.fillRect(x, y, slotWidth, slotHeight);
ctx.restore();
} else if (index === filled) {
ctx.save();
ctx.globalAlpha = 0.35 + 0.3 * Math.sin(now / 200);
ctx.fillStyle = color;
const progress = (time % (total / 6)) / (total / 6);
ctx.fillRect(x, y, slotWidth * progress, slotHeight);
ctx.restore();
}
}
const dots = ".".repeat(1 + Math.floor(now / 450) % 3);
const loadingText = scene === "fortune" ? "推 演 运 气 · 加 载 中" : "推 演 天 机 · 加 载 中";
this.label(`${loadingText}${dots}`, width / 2, height - 32, 13, GOLD, 0.75);
}
drawTrigramRing(cx, cy, radius, width, lineWidth, alpha, now, entering, time) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha * 0.13;
ctx.strokeStyle = GOLD;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
const breath = 1 + 0.006 * Math.sin(now / 620);
TRIGRAMS.forEach((trigram, index) => {
const progress = entering ? easeOut((time - 4700 - index * 130) / 700) : 1;
if (progress <= 0) return;
const [x, y] = point(cx, cy, radius * breath * progress, trigram.angle);
this.drawGua(x, y, width, lineWidth, trigram.bits, alpha * progress, alpha * progress * 8);
const nameAlpha = entering ? alpha * clamp01((time - 4700 - index * 130 - 480) / 500) : alpha;
this.label(trigram.name, x, y + lineWidth * 5.2, 13, GOLD, nameAlpha * (0.55 + 0.2 * Math.sin(now / 700 + index)));
});
}
drawHexagram(time, now) {
const { width, height } = this;
const cx = width / 2;
const cy = height * 0.4;
const scale = Math.min(width, Math.max(1, height - 150));
if (time < 1800) {
const alpha = this.stageAlpha(time, 0, 1800);
this.node(cx, cy, 5.5 * (1 + 0.12 * Math.sin(now / 260)), GOLD_BRIGHT, alpha, 34);
for (let ring = 0; ring < 3; ring += 1) {
const progress = ((now / 1500) + ring / 3) % 1;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = (1 - progress) * 0.22 * alpha;
ctx.strokeStyle = GOLD;
ctx.beginPath();
ctx.arc(cx, cy, 8 + progress * scale * 0.13, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
}
if (time >= 1800 && time < 3300) {
const alpha = this.stageAlpha(time, 1800, 3300);
const progress = easeOut((time - 1850) / 850);
const yaoWidth = scale * 0.19 * progress;
const yaoLine = Math.max(scale * 0.013, 5);
this.drawYao(cx, cy - yaoLine * 2.6, yaoWidth, yaoLine, true, alpha, 14);
this.drawYao(cx, cy + yaoLine * 2.6, yaoWidth, yaoLine, false, alpha, 14);
this.node(cx, cy, 4, GOLD_BRIGHT, alpha * (1 - progress) * 0.9);
}
if (time >= 3300 && time < 4700) {
const alpha = this.stageAlpha(time, 3300, 4700);
const distance = scale * 0.085;
const yaoWidth = Math.max(scale * 0.055, 28);
const yaoLine = Math.max(scale * 0.009, 3.5);
SIXIANG.forEach((symbol, index) => {
const progress = easeOut((time - 3330 - index * 160) / 520);
if (progress <= 0) return;
const x = cx + symbol.dx * distance;
const y = cy + symbol.dy * distance;
this.drawGua(x, y, yaoWidth * progress, yaoLine, symbol.bits, alpha * progress, 10);
this.label(symbol.name, x, y + yaoLine * 5.4, 12, GOLD, alpha * progress * 0.55);
});
}
const trigramRadius = scale * 0.215;
const trigramWidth = Math.max(scale * 0.052, 26);
const trigramLine = Math.max(scale * 0.0075, 3);
if (time >= 4700 && time < 6800) {
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth, trigramLine, this.stageAlpha(time, 4700, 6800), now, true, time);
}
if (time >= 6800 && time < 10800) {
const alpha = this.stageAlpha(time, 6800, 10800, 350);
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth * 0.85, trigramLine * 0.85, alpha * 0.42, now, false, time);
const ringRadius = scale * 0.365;
const hexWidth = Math.max(scale * 0.026, 13);
const hexLine = Math.max(scale * 0.0042, 1.6);
const count = Math.floor(clamp01((time - 7000) / 3600) * 64);
for (let index = 0; index < 64; index += 1) {
const [x, y] = point(cx, cy, ringRadius, -90 + index * 360 / 64);
this.node(x, y, 1.4, GOLD, alpha * 0.14);
if (index < count) {
const freshness = Math.max(0, 1 - (count - 1 - index) / 5);
if (freshness > 0) {
const ctx = this.context;
const gradient = ctx.createLinearGradient(cx, cy, x, y);
gradient.addColorStop(0, "rgba(230,195,122,0)");
gradient.addColorStop(1, GOLD);
this.line(cx, cy, x, y, gradient, alpha * freshness * 0.35);
}
this.drawGua(x, y, hexWidth, hexLine, hexBits(index), alpha * (0.55 + 0.45 * freshness), freshness * 9);
}
}
if (count > 0) {
const current = count - 1;
const popTime = clamp01((time - (7000 + current * 3600 / 64)) / 130);
const pop = 1 + 0.22 * (1 - popTime);
this.drawGua(cx, cy - scale * 0.028, scale * 0.085 * pop, Math.max(scale * 0.011, 4.5), hexBits(current), alpha, 16);
this.label(HEXAGRAM_NAMES[current], cx, cy + scale * 0.062, Math.max(20, scale * 0.042), GOLD_BRIGHT, alpha, "600");
this.label(`${current + 1}`, cx, cy + scale * 0.105, 13, GOLD, alpha * 0.55);
}
}
if (time >= 10800) {
const alpha = this.stageAlpha(time, 10800, HEX_TOTAL, 420);
const progress = easeOut((time - 10850) / 1150);
const radius = scale * 0.365 * (1 - progress);
for (let index = 0; index < 64 && radius >= 8; index += 1) {
const [x, y] = point(cx, cy, radius, -90 + index * 360 / 64);
this.drawGua(x, y, Math.max(scale * 0.026, 13), Math.max(scale * 0.0042, 1.6), hexBits(index), (1 - progress) * 0.7 * alpha);
}
this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40);
}
this.drawFooter(time, now, HEX_TOTAL, HEX_STAGES, "hexagram");
}
drawFortune(time, now) {
const { width, height } = this;
const cx = width / 2;
const cy = height * 0.4;
const scale = Math.min(width, Math.max(1, height - 150));
if (time < 2100) this.drawFiveMovements(time, now, cx, cy, scale);
if (time >= 2100 && time < 3900) this.drawStems(time, cx, cy, scale);
if (time >= 3900 && time < 5800) this.drawBranches(time, cx, cy, scale);
if (time >= 5800 && time < 7900) this.drawSixQi(time, now, cx, cy, scale);
if (time >= 7900 && time < 11000) this.drawAnnualQi(time, now, cx, cy, scale);
if (time >= 11000) {
const alpha = this.stageAlpha(time, 11000, FORTUNE_TOTAL, 420);
const progress = easeOut((time - 11050) / 1200);
const radius = scale * 0.30 * (1 - progress);
QI6.forEach((qi, index) => {
const [x, y] = point(cx, cy, radius, -90 + index * 60);
if (radius > 8) this.node(x, y, Math.max(scale * 0.011, 6), ELEMENT_COLORS[qi.element], (1 - progress) * 0.8 * alpha, 8);
});
this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40);
}
this.drawFooter(time, now, FORTUNE_TOTAL, this.fortuneStages(), "fortune");
}
drawFiveMovements(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 0, 2100);
const radius = scale * 0.17;
const nodeRadius = Math.max(scale * 0.018, 9);
const elements = [
["木", 180], ["火", -90], ["金", 0], ["水", 90], ["土", null],
];
const positions = {};
this.node(cx, cy, 5 + 1.5 * Math.sin(now / 260), GOLD_BRIGHT, alpha * (1 - easeOut((time - 200) / 800)), 30);
elements.forEach(([element, degrees], index) => {
const progress = easeOut((time - 500 - index * 170) / 500);
if (progress <= 0) return;
const x = degrees === null ? cx : cx + Math.cos(degrees * Math.PI / 180) * radius * progress;
const y = degrees === null ? cy : cy + Math.sin(degrees * Math.PI / 180) * radius * progress;
positions[element] = [x, y];
this.node(x, y, nodeRadius * progress, ELEMENT_COLORS[element], alpha * progress, 16);
this.label(element, x, y + 0.5, Math.round(nodeRadius * 1.15), NODE_TEXT, alpha * progress, "600");
const direction = element === "土" ? "中央土" : { : "东方木", : "南方火", : "西方金", : "北方水" }[element];
this.label(direction, x, y + nodeRadius + 14, 12, ELEMENT_COLORS[element], alpha * progress * 0.75);
});
const order = ["木", "火", "土", "金", "水"];
order.forEach((element, index) => {
const from = positions[element];
const to = positions[order[(index + 1) % order.length]];
if (!from || !to) return;
const progress = smooth(1450 + index * 130, 1700 + index * 130, time);
const mx = (from[0] + to[0]) / 2 + (cx - (from[0] + to[0]) / 2) * 0.25;
const my = (from[1] + to[1]) / 2 + (cy - (from[1] + to[1]) / 2) * 0.25;
this.curvedArrow(from[0], from[1], to[0], to[1], mx, my, GOLD, alpha * progress * 0.4);
});
}
drawStems(time, cx, cy, scale) {
const alpha = this.stageAlpha(time, 2100, 3900);
const stems = "甲乙丙丁戊己庚辛壬癸";
const movements = ["土", "金", "水", "木", "火"];
const radius = scale * 0.30;
for (let index = 0; index < 10; index += 1) {
const progress = smooth(2150 + index * 90, 2450 + index * 90, time);
if (progress <= 0) continue;
const [x, y] = point(cx, cy, radius, -90 + index * 36);
const element = movements[index % 5];
this.node(x, y, 3, ELEMENT_COLORS[element], alpha * progress, 8);
this.label(stems[index], x, y - 14, 15, ELEMENT_COLORS[element], alpha * progress, "600");
}
for (let index = 0; index < 5; index += 1) {
const progress = smooth(3150 + index * 110, 3450 + index * 110, time);
const angle = -90 + index * 36;
const [x1, y1] = point(cx, cy, radius, angle);
const [x2, y2] = point(cx, cy, radius, -90 + (index + 5) * 36);
this.line(x1, y1, x2, y2, ELEMENT_COLORS[movements[index]], alpha * progress * 0.45);
const [labelX, labelY] = point(cx, cy, scale * 0.055, angle + 90);
this.label(movements[index], labelX, labelY, 16, ELEMENT_COLORS[movements[index]], alpha * progress, "600");
}
}
drawBranches(time, cx, cy, scale) {
const alpha = this.stageAlpha(time, 3900, 5800);
const branches = "子丑寅卯辰巳午未申酉戌亥";
const qiNames = ["少阴君火", "太阴湿土", "少阳相火", "阳明燥金", "太阳寒水", "厥阴风木"];
const radius = scale * 0.31;
const branchAngle = (index) => -90 + ((index - 6 + 12) % 12) * 30;
for (let index = 0; index < 12; index += 1) {
const progress = smooth(3950 + index * 70, 4220 + index * 70, time);
const [x, y] = point(cx, cy, radius, branchAngle(index));
this.node(x, y, 2.5, GOLD, alpha * progress, 6);
this.label(branches[index], x, y - 13, 14, GOLD, alpha * progress * 0.9);
}
qiNames.forEach((name, index) => {
const progress = smooth(4900 + index * 130, 5200 + index * 130, time);
const [x1, y1] = point(cx, cy, radius, branchAngle(index));
const [x2, y2] = point(cx, cy, radius, branchAngle(index + 6));
const element = QI6.find((item) => item.name === name)?.element || "土";
this.line(x1, y1, x2, y2, ELEMENT_COLORS[element], alpha * progress * 0.4);
const [labelX, labelY] = point(cx, cy, radius + scale * 0.055, branchAngle(index));
this.label(name, labelX, labelY, 12, ELEMENT_COLORS[element], alpha * progress, "600");
});
}
drawSixQi(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 5800, 7900);
const radius = scale * 0.27;
const drift = now * 0.004;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha * 0.13;
ctx.strokeStyle = GOLD;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
QI6.forEach((qi, index) => {
const progress = easeOut((time - 5850 - index * 180) / 550);
const [x, y] = point(cx, cy, radius * progress, -90 + index * 60 + drift);
const nodeRadius = Math.max(scale * 0.015, 8) * progress;
this.node(x, y, nodeRadius, ELEMENT_COLORS[qi.element], alpha * progress, 14);
this.label(qi.name, x, y - nodeRadius - 12, 13, ELEMENT_COLORS[qi.element], alpha * progress, "600");
this.label(["初之气", "二之气", "三之气", "四之气", "五之气", "终之气"][index], x, y + nodeRadius + 12, 10.5, DIM, alpha * progress * 0.9);
});
this.node(cx, cy, 4 + Math.sin(now / 300), GOLD_BRIGHT, alpha * 0.9, 24);
}
drawAnnualQi(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 7900, 11000, 350);
const sixQi = this.data.sixQi || {};
const pillar = this.data.yearPillar || "岁运";
const movement = this.data.movement || "中运合参";
const sitian = sixQi.sitian || "司天气候";
const zaiquan = sixQi.zaiquan || "在泉气化";
const currentStep = Math.max(1, Math.min(6, Number(sixQi.step) || 1));
const qiElement = (name) => QI6.find((item) => item.name === name)?.element || "土";
const movementElement = ["木", "火", "土", "金", "水"].find((element) => movement.includes(element)) || "土";
this.label("司 天", cx, cy - scale * 0.212, 11, DIM, alpha * smooth(7950, 8450, time));
this.label(sitian, cx, cy - scale * 0.178, 17, ELEMENT_COLORS[qiElement(sitian)], alpha * smooth(7950, 8450, time), "600");
this.label(zaiquan, cx, cy + scale * 0.178, 17, ELEMENT_COLORS[qiElement(zaiquan)], alpha * smooth(8200, 8700, time), "600");
this.label("在 泉", cx, cy + scale * 0.212, 11, DIM, alpha * smooth(8200, 8700, time));
this.label(pillar, cx, cy - scale * 0.012, Math.max(22, scale * 0.052), GOLD_BRIGHT, alpha * smooth(8500, 9100, time), "600");
this.label(`${pillar}年 · 中运${movement}`, cx, cy + scale * 0.052, 14, ELEMENT_COLORS[movementElement], alpha * smooth(8500, 9100, time), "600", scale * 0.62);
const radius = scale * 0.30;
QI6.forEach((qi, index) => {
const progress = smooth(9200 + index * 260, 9480 + index * 260, time);
const [x, y] = point(cx, cy, radius, -90 + index * 60);
const current = index + 1 === currentStep;
const pulse = current ? 0.5 + 0.5 * Math.sin(now / 230) : 0;
this.node(x, y, Math.max(scale * 0.011, 6) + (current ? 2.5 : 0), ELEMENT_COLORS[qi.element], alpha * progress, 12 + pulse * 14);
if (current) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha * (0.35 + pulse * 0.35);
ctx.strokeStyle = CINNABAR;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(x, y, Math.max(scale * 0.02, 11) + pulse * 3, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
this.label("当今", x, y - Math.max(scale * 0.038, 21), 10.5, CINNABAR, alpha * progress, "600");
}
const stepName = `${index + 1 === 6 ? "终" : ["初", "二", "三", "四", "五"][index]}之气`;
this.label(`${stepName} · ${qi.name}`, x, y + Math.max(scale * 0.03, 17), 11.5, current ? GOLD_BRIGHT : ELEMENT_COLORS[qi.element], alpha * progress * (current ? 1 : 0.85), current ? "600" : "");
if (current) this.label(STEP_RANGES[index], x, y + Math.max(scale * 0.052, 33), 10, DIM, alpha * progress);
});
}
drawCompletion(progress, now) {
this.drawBackground(now);
if (this.scene === "fortune") {
this.drawFortune(11000 + progress * (FORTUNE_TOTAL - 11000), now);
} else {
this.drawHexagram(10800 + progress * (HEX_TOTAL - 10800), now);
}
}
}
global.HeavenLoadingCanvas = HeavenLoadingCanvas;
})(window);
+180 -145
View File
@@ -3,12 +3,26 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<meta name="color-scheme" content="light dark">
<title>小白复盘</title>
<script>
(() => {
let theme = "light";
try {
theme = localStorage.getItem("xiaobaiTheme") === "dark" ? "dark" : "light";
} catch (_error) {
theme = "light";
}
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
})();
</script>
<link rel="stylesheet" href="/styles.css">
<link rel="stylesheet" href="/renovation.css?v=20260725-5">
<link rel="stylesheet" href="/redesign-v2.css?v=20260726-33">
<link rel="stylesheet" href="/design-system.css?v=20260726-7">
<link rel="stylesheet" href="/redesign-v2.css?v=20260726-34">
<link rel="stylesheet" href="/design-system.css?v=20260728-4">
<link rel="stylesheet" href="/theme.css?v=20260728-1">
<link rel="stylesheet" href="/wentian-v2.css?v=20260728-7">
</head>
<body>
<section id="authGate" class="auth-gate" aria-label="账号登录">
@@ -47,6 +61,7 @@
<button id="nextDate" class="icon-button" type="button" title="后一个交易日" aria-label="后一个交易日"><i data-lucide="chevron-right"></i></button>
</div>
<button id="globalSearchButton" class="icon-button global-search-button" type="button" title="全局搜索(Ctrl+K" aria-label="全局搜索"><i data-lucide="search"></i></button>
<button id="themeToggle" class="icon-button theme-toggle" type="button" title="切换到夜间模式" aria-label="切换到夜间模式" aria-pressed="false"><i data-lucide="moon"></i></button>
<button id="alertButton" class="icon-button alert-button" type="button" title="提醒中心" aria-label="提醒中心"><i data-lucide="bell"></i><span id="alertBadge" class="alert-badge" hidden>0</span></button>
<button id="assistantButton" class="icon-button assistant-button" type="button" title="复盘助手" aria-label="复盘助手"><i data-lucide="message-circle-more"></i></button>
<button id="headerMenuButton" class="icon-button header-menu-button" type="button" title="打开命令菜单" aria-label="打开命令菜单" aria-expanded="false" aria-controls="headerCommandGroup"><i data-lucide="ellipsis"></i></button>
@@ -498,40 +513,49 @@
</div>
</section>
<section id="heavenView" class="workspace-view member-feature-view">
<section id="heavenView" class="workspace-view member-feature-view heaven-shell wt">
<div class="member-gate" hidden><div class="member-gate-icon"><i data-lucide="lock-keyhole"></i></div><div><strong>问天仅对会员开放</strong><span>开通会员后可使用观势、观气、观心及平台解读。会员状态可从顶部账号标识进入。</span></div></div>
<div class="section-toolbar heaven-toolbar">
<div class="section-title-group">
<h2><span class="heaven-title-seal" aria-hidden="true"></span></h2>
<span id="heavenDataDate" class="section-subtitle">--</span>
<header class="wt-head">
<div class="wt-title-line">
<h1 class="wt-serif"></h1>
<span id="heavenDataDate">--</span>
</div>
</div>
<div class="heaven-tabs" role="tablist" aria-label="问天模块">
<button class="heaven-tab active" type="button" data-heaven-panel="trend">观势</button>
<button class="heaven-tab" type="button" data-heaven-panel="fortune">观气</button>
<button class="heaven-tab" type="button" data-heaven-panel="heart">观心</button>
</div>
<p class="heaven-proverb">遇事不决可问春风,春风不语即随本心</p>
<div id="heavenNotice" class="inline-notice" hidden></div>
<div class="verse wt-serif">观天之道 · 执天之行</div>
<nav class="wt-tabs" aria-label="问天模块">
<button class="wt-tab wt-serif on" type="button" data-heaven-panel="trend" aria-current="page">观势<small>三才六爻 · 量化成卦</small></button>
<button class="wt-tab wt-serif" type="button" data-heaven-panel="fortune">观气<small>五运六气 · 日辰生克</small></button>
<button class="wt-tab wt-serif" type="button" data-heaven-panel="heart">观心<small>静心占卜 · 第一念</small></button>
</nav>
</header>
<p class="heaven-proverb wt-serif">遇事不决可问春风,春风不语即随本心</p>
<div id="heavenNotice" class="inline-notice" role="status" hidden></div>
<section id="heavenTrendPanel" class="heaven-panel active-heaven-panel">
<div class="heaven-controls">
<div class="heaven-stock-query">
<label class="form-field"><span>股票代码或名称</span><input id="heavenStockInput" type="text" inputmode="text" maxlength="30" placeholder="例如 600000 或 浦发银行"></label>
<label class="form-field" for="heavenStockInput"><span>股票代码或名称</span><input id="heavenStockInput" type="text" inputmode="text" maxlength="30" autocomplete="off" placeholder="例如 600000 或 浦发银行"></label>
<div id="heavenStockIdentity" class="heaven-stock-identity" aria-live="polite" hidden>
<span>当前标的</span>
<span>当前标的</span>
<strong id="heavenStockName">--</strong>
<small id="heavenStockSector">--</small>
<span id="heavenStockTaxonomy">申万二级 ·</span>
<strong id="heavenStockSector">--</strong>
</div>
</div>
<div class="heaven-trend-actions">
<button id="loadHeavenSelectionButton" class="button" type="button">载入</button>
<button id="historyTrendButton" class="button" type="button"><i data-lucide="history"></i><span>历史记录</span></button>
<button id="historyTrendButton" class="button" type="button">历史记录</button>
<button id="interpretTrendButton" class="button primary" type="button" disabled>解势</button>
</div>
</div>
<div id="heavenTrendEmpty" class="heaven-trend-empty" role="status">请输入股票代码或股票名称</div>
<div class="heaven-trend-layout" hidden>
<div class="cast-hint wt-serif">载入,以指数为天、行业为人、个股为地,六爻皆由行情量化而成</div>
<div class="wt-stage">
<div class="stars" id="stars" aria-hidden="true"></div>
<svg class="bagua" id="baguaSvg" width="560" height="560" viewBox="0 0 300 300" aria-hidden="true"></svg>
<div id="heavenTrendEmpty" class="wt-empty" role="status">
<div class="big wt-serif">三才六爻</div>
<p class="wt-serif">指数外显为上爻 · 内核为五爻 · 行业外显为四爻 · 内核为三爻 · 个股外显为二爻 · 内核为初爻<br>六爻皆由行情量化而成 —— 输入标的,点「载入」成卦</p>
</div>
<div class="heaven-trend-layout stage-in" hidden>
<section class="hexagram-board">
<div class="hexagram-heading">
<div><span class="metric-label">三才六爻</span><h3 id="marketHexagramName">--</h3></div>
@@ -551,184 +575,164 @@
<div class="trend-score-marks"><span>-100 · 势衰</span><span>0</span><span>+100 · 势盛</span></div>
</div>
<div id="threeTalentReadings" class="three-talent-readings"></div>
<div id="heavenIndexStrip" class="heaven-index-strip"></div>
<details class="trend-evidence-panel">
<summary>三才取象之据<i data-lucide="chevron-down" aria-hidden="true"></i></summary>
<div id="heavenTrendEvidence" class="trend-evidence-body"></div>
</details>
<div class="heaven-hex-transition" aria-label="本卦与之卦">
<figure class="compact-hex-figure">
<div id="heavenOriginalHexLines" class="compact-hex-lines" aria-hidden="true"></div>
<figcaption><strong>本卦 · <b id="heavenOriginalHexName">--</b></strong><small id="heavenOriginalHexDetail">--</small></figcaption>
</figure>
<div class="compact-hex-change"><i aria-hidden="true"></i><span>动而之卦</span></div>
<figure class="compact-hex-figure">
<div id="heavenChangedHexLines" class="compact-hex-lines" aria-hidden="true"></div>
<figcaption><strong>之卦 · <b id="heavenChangedHexName">--</b></strong><small id="heavenChangedHexDetail">--</small></figcaption>
</figure>
</div>
</section>
</div>
<section id="heavenCalibrationPanel" class="heaven-calibration-panel" aria-labelledby="heavenCalibrationTitle" hidden>
<div class="heaven-calibration-heading">
<div>
<span class="metric-label">量化数据安全门</span>
<h3 id="heavenCalibrationTitle">六爻数据校验</h3>
</div>
</div>
<details id="heavenCalibrationPanel" class="heaven-calibration-panel" hidden>
<summary class="heaven-calibration-heading">
<div><span class="metric-label">量化数据安全门</span><h3 id="heavenCalibrationTitle">六爻数据校验</h3></div>
<div class="heaven-calibration-summary">
<span><i class="status-dot passed"></i>通过</span>
<span><i class="status-dot failed"></i>未通过</span>
<span><i class="status-dot manual"></i>用户补录</span>
<strong id="heavenCalibrationStatus">等待载入</strong>
<b class="fold-label">展开</b>
</div>
</summary>
<div class="calibration-body">
<p>系统只接收客观行情数据,所有补录仍按原量化公式计算阴阳与动爻。</p>
<form id="heavenCalibrationForm">
<div id="heavenLineChecks" class="heaven-line-checks" aria-live="polite"></div>
<label class="form-field calibration-note-field"><span>补录说明</span><input id="heavenCalibrationNote" type="text" maxlength="200" placeholder="可选:记录数据来源或补录原因"></label>
<div class="dialog-actions heaven-calibration-actions">
<button id="resetHeavenCalibrationButton" class="button" type="button">恢复自动数据</button>
<button id="applyHeavenCalibrationButton" class="button primary" type="submit">重新校验并成卦</button>
</div>
</form>
</div>
<p>系统只接收客观行情数据,所有补录仍按原量化公式计算阴阳与动爻。</p>
<form id="heavenCalibrationForm">
<div id="heavenLineChecks" class="heaven-line-checks" aria-live="polite"></div>
<label class="form-field calibration-note-field"><span>补录说明</span><input id="heavenCalibrationNote" type="text" maxlength="200" placeholder="可选:记录数据来源或补录原因"></label>
<div class="dialog-actions heaven-calibration-actions">
<button id="resetHeavenCalibrationButton" class="button" type="button">恢复自动数据</button>
<button id="applyHeavenCalibrationButton" class="button primary" type="submit">重新校验并成卦</button>
</div>
</form>
</section>
</details>
</section>
<section id="heavenFortunePanel" class="heaven-panel">
<div class="fortune-heading">
<div class="fortune-calendar-heading"><span id="fortuneLunarDate" class="metric-label">--</span><h3 id="fortunePillars">--</h3></div>
<div class="fortune-calendar-heading"><span id="fortuneLunarDate" class="metric-label">--</span><h3 id="fortunePillars" class="wt-serif">--</h3></div>
<div class="fortune-heading-actions">
<label class="qi-time-field"><span>观测日期</span><input id="qiObservationDate" type="date"></label>
<button id="historyFortuneButton" class="button" type="button"><i data-lucide="history"></i><span>历史记录</span></button>
<button id="interpretFortuneButton" class="button primary" type="button">解运</button>
<button id="historyFortuneButton" class="button" type="button">历史记录</button>
<button id="interpretFortuneButton" class="button primary" type="button" disabled>解运</button>
</div>
</div>
<div class="qi-hero">
<section class="human-field-panel qi-climate-panel">
<canvas id="qiFieldCanvas" aria-hidden="true"></canvas>
<div class="qi-climate-center">
<span class="qi-section-mark">壹 · 天</span>
<div class="qi-climate-heading">
<div><span>今日气候</span><h3 id="qiClimateKeyword">--</h3></div>
<strong id="qiClimateTone">--</strong>
</div>
<p id="humanFieldSummary" class="human-field-summary">--</p>
</div>
<div class="fortune-stage">
<div class="stars" id="fortuneStars" aria-hidden="true"></div>
<svg class="bagua fortune-bagua" id="fortuneBagua" width="560" height="560" viewBox="0 0 300 300" aria-hidden="true"></svg>
<section class="qi-climate-panel">
<span class="qi-section-mark">壹 · 天</span>
<p class="qi-climate-caption wt-serif">今日气候</p>
<h3 id="qiClimateKeyword" class="wt-serif">气机待察</h3>
<strong id="qiClimateTone" class="wt-serif">--</strong>
<p id="humanFieldSummary" class="human-field-summary">--</p>
</section>
<section class="five-phase-panel">
<div class="workspace-heading"><h3>五行流转</h3><span>强弱 · 心性 · 流向</span></div>
<div id="fivePhaseBalance" class="five-phase-balance"></div>
</section>
</div>
<section class="qi-framework-panel">
<div class="workspace-heading"><h3>三层气机</h3><span id="qiFrameworkPrinciple">--</span></div>
<div id="qiFrameworkLayers" class="qi-framework-layers"></div>
</section>
<section class="qi-instincts-panel">
<div class="human-field-grid">
<div><span><i data-lucide="heart-pulse"></i>容易生起</span><strong id="humanEmotionList">--</strong></div>
<div><span><i data-lucide="scan-eye"></i>判断偏差</span><strong id="humanBiasList">--</strong></div>
<div><span><i data-lucide="mouse-pointer-click"></i>操作惯性</span><strong id="humanOperation">--</strong></div>
<div><span><i data-lucide="shield-check"></i>风险与制衡</span><strong id="humanBalanceActions">--</strong></div>
</div>
</section>
<section class="personal-fortune-panel">
<div class="workspace-heading"><h3>个人影响</h3><span>日主、十神喜恶、五行喜忌与当日作用</span></div>
<div id="personalProfileEmpty" class="personal-profile-empty">
<span>当前账号尚未设置个人命理资料</span>
<button id="openPersonalSettingsButton" class="button" type="button">前往设置</button>
</div>
<div id="personalFortuneResult" class="personal-fortune-result" hidden></div>
</section>
<section class="qi-use-panel">
<div class="workspace-heading"><div><span>叁 · 用</span><h3 id="phaseSectorTitle">五行行业归属</h3></div><span id="phaseSectorContext">传统取象 · 手动归类优先</span></div>
<div id="qiUseMap" class="qi-use-map">
<div id="qiUseSources" class="qi-use-sources" aria-label="五行气场来源"></div>
<svg id="qiUseConnections" class="qi-use-connections" aria-hidden="true"></svg>
<div id="phaseSectorList" class="phase-sector-list qi-sector-groups" aria-label="五行行业归类"></div>
</div>
</section>
<details class="phase-sector-panel qi-evidence-panel">
<summary class="qi-evidence-summary">
<div><span>次要信息</span><h3>历法细目与归类管理</h3></div>
<span>展开查看推演依据</span>
<i data-lucide="chevron-down" aria-hidden="true"></i>
</summary>
<div class="qi-evidence-body">
<section class="qi-detail-section">
<div class="workspace-heading"><h3>历法细目</h3><span>中运、司天、在泉与节气定位</span></div>
<div id="fortuneMetrics" class="fortune-metrics"></div>
<aside class="fortune-basics">
<section class="qi-framework-panel">
<div class="workspace-heading"><div><span>贰 · 气</span><h3 class="wt-serif">三层气机</h3></div><button class="text-fold-button" type="button" data-fold-target="qiFrameworkLayers">收起</button></div>
<p id="qiFrameworkPrinciple" class="qi-framework-principle">--</p>
<div id="qiFrameworkLayers" class="qi-framework-layers"></div>
</section>
<details id="sectorPhaseManager" class="sector-phase-manager">
<summary class="sector-phase-manager-heading"><strong>管理手动归类</strong><span>精确名称优先</span><i data-lucide="chevron-down" aria-hidden="true"></i></summary>
<div class="sector-phase-manager-body">
<form id="sectorPhaseForm" class="sector-phase-form">
<input id="sectorPhaseName" type="text" maxlength="50" placeholder="行业或题材名称" aria-label="行业或题材名称" required>
<select id="sectorPhaseElement" aria-label="五行归类"><option value="木"></option><option value="火"></option><option value="土"></option><option value="金"></option><option value="水"></option></select>
<button class="button" type="submit">保存归类</button>
</form>
<div id="sectorPhaseOverrides" class="sector-phase-overrides"></div>
</div>
</details>
</div>
<section class="personal-fortune-panel">
<div class="workspace-heading"><div><span>叁 · 人</span><h3 class="wt-serif">个人合参</h3></div></div>
<div id="personalProfileEmpty" class="personal-profile-empty"><span>当前账号尚未设置个人命理资料</span></div>
<div id="personalFortuneResult" class="personal-fortune-result" hidden></div>
</section>
</aside>
</div>
<details id="fortuneSectorCatalog" class="fortune-sector-catalog">
<summary>
<span><small>五行取象</small><strong class="wt-serif">五行对应行业</strong></span>
<span class="fortune-sector-summary-hint">展开查看全部行业 <i aria-hidden="true"></i></span>
</summary>
<div id="fortuneSectorGroups" class="fortune-sector-groups"></div>
</details>
<p id="fortuneNotice" class="heaven-footnote"></p>
<div class="wentian-legacy-hooks" hidden aria-hidden="true">
<canvas id="qiFieldCanvas"></canvas>
<div id="heavenIndexStrip"></div><div id="heavenTrendEvidence"></div>
<div id="fortuneMetrics"></div><div id="fivePhaseBalance"></div>
<div id="humanEmotionList"></div><div id="humanBiasList"></div><div id="humanOperation"></div><div id="humanBalanceActions"></div>
<div id="phaseSectorTitle"></div><div id="phaseSectorContext"></div>
<div id="qiUseMap"><div id="qiUseSources"></div><svg id="qiUseConnections"></svg><div id="phaseSectorList"></div></div>
<button id="openPersonalSettingsButton" type="button"></button>
<details id="sectorPhaseManager"><summary>归类管理</summary><form id="sectorPhaseForm"><input id="sectorPhaseName"><select id="sectorPhaseElement"><option value="木"></option></select><button type="submit">保存</button></form><div id="sectorPhaseOverrides"></div></details>
</div>
</section>
<section id="heavenHeartPanel" class="heaven-panel">
<canvas id="heartDustCanvas" class="heart-dust-canvas" aria-hidden="true"></canvas>
<div id="heartLamp" class="heart-lamp" aria-hidden="true"></div>
<div class="heart-toolbar-controls">
<button id="historyHeartButton" class="heart-history-button" type="button" aria-label="查看观心历史记录" title="历史记录">
<i data-lucide="history"></i><span>历史记录</span>
</button>
<button id="heartSoundToggle" class="heart-sound-toggle" type="button" aria-pressed="false" aria-label="开启观心声音" title="声音">
<i data-lucide="volume-x"></i><span>静音</span>
</button>
<div class="heart-journey" aria-label="观心进程">
<span class="active" data-heart-step="intro"><i></i>静心</span><b></b>
<span data-heart-step="breathing"><i></i>呼吸</span><b></b>
<span data-heart-step="casting"><i></i>起卦</span><b></b>
<span data-heart-step="reveal"><i></i>察念</span><b></b>
<span data-heart-step="interpretation"><i></i>解卦</span>
</div>
<div id="heartRitualCurtain" class="heart-ritual-curtain" aria-hidden="true">
<i class="heart-daybreak-dark"></i><strong>观 心</strong><span>心静,而后问</span>
</div>
<div id="heartIntro" class="heart-stage active-heart-stage">
<div class="heart-stage-shell">
<canvas id="heartDustCanvas" hidden></canvas><div id="heartLamp" hidden></div><div id="heartRitualCurtain" hidden></div><div id="heartLineTexts" hidden></div>
<div class="stars" id="heartStars" aria-hidden="true"></div>
<svg class="bagua heart-bagua" id="heartBagua" width="560" height="560" viewBox="0 0 300 300" aria-hidden="true"></svg>
<div id="heartWhispers" class="heart-whispers" aria-hidden="true"></div>
<div class="heart-toolbar-controls">
<button id="historyHeartButton" class="button" type="button">历史记录</button>
<button id="heartSoundToggle" class="button" type="button" aria-pressed="false">静音</button>
</div>
<div id="heartIntro" class="heart-stage active-heart-stage">
<div class="heart-stage-inner">
<span class="heart-stage-index heart-rise" data-heart-delay="0">观心 · 一</span>
<h3 class="heart-rise" data-heart-delay="420">把所问之事留在心里</h3>
<h3 class="heart-rise wt-serif" data-heart-delay="420">把所问之事留在心里</h3>
<div class="heart-guidance heart-rise" data-heart-delay="900">
<p>只问一事,不必说出来。</p>
<p>心里默念它发生的对象与时间。</p>
<p>不求一个喜欢的答案,只看自己真正担心什么。</p>
</div>
<blockquote class="heart-motto heart-rise" data-heart-delay="1500">遇事不决可问春风,春风不语即随本心</blockquote>
<blockquote class="heart-motto heart-rise wt-serif" data-heart-delay="1500">遇事不决可问春风,春风不语即随本心</blockquote>
<button id="startBreathingButton" class="button primary heart-rise" data-heart-delay="2200" type="button">开始静心</button>
</div>
</div>
<div id="heartBreathing" class="heart-stage">
<button class="button heart-return-button" type="button" data-heart-return><i data-lucide="arrow-left"></i><span>返回</span></button>
<button class="button heart-return-button" type="button" data-heart-return>← 返回</button>
<div class="heart-stage-inner breathing-stage">
<span class="heart-stage-index">观心 · 二</span>
<div id="breathingScene" class="breathing-scene" data-phase="prepare">
<div class="heart-breath-ripple" aria-hidden="true"><span></span><span></span><span></span><i></i></div>
<b id="breathingPhase" class="breathing-phase" role="status" aria-live="polite"></b>
</div>
<h3 id="breathingPrompt">放松片刻,准备呼吸</h3>
<h3 id="breathingPrompt" class="wt-serif">放松片刻,准备呼吸</h3>
<div class="heart-incense" aria-hidden="true"><i id="heartIncenseEmber"></i></div>
<button id="beginCastingButton" class="button primary" type="button" disabled>静心完成,开始起卦</button>
</div>
</div>
<div id="heartCasting" class="heart-stage">
<button class="button heart-return-button" type="button" data-heart-return><i data-lucide="arrow-left"></i><span>返回</span></button>
<button class="button heart-return-button" type="button" data-heart-return>← 返回</button>
<div class="heart-casting-layout">
<section class="heart-hexagram-shell">
<div class="workspace-heading"><h3>从初爻起</h3><span id="castingProgress">0 / 6</span></div>
<div class="workspace-heading"><h3 class="wt-serif">从初爻起</h3><span id="castingProgress">0 / 6</span></div>
<div id="heartCastingLines" class="hexagram-lines ritual-lines"></div>
</section>
<section class="casting-action-panel">
<span class="heart-stage-index">观心 · 三</span>
<div id="coinResult" class="heart-coins" aria-label="三枚铜钱">
<div class="heart-coin" data-coin-index="0"><div class="heart-coin-inner"><span class="heart-coin-face front"></span><span class="heart-coin-face back"></span></div><i class="heart-coin-ring"></i></div>
<div class="heart-coin" data-coin-index="1"><div class="heart-coin-inner"><span class="heart-coin-face front"></span><span class="heart-coin-face back"></span></div><i class="heart-coin-ring"></i></div>
<div class="heart-coin" data-coin-index="2"><div class="heart-coin-inner"><span class="heart-coin-face front"></span><span class="heart-coin-face back"></span></div><i class="heart-coin-ring"></i></div>
<div class="heart-coin" data-coin-index="0"><div class="heart-coin-inner"><span class="heart-coin-face front" aria-label="字面"><b class="coin-glyph coin-glyph-top"></b><b class="coin-glyph coin-glyph-right"></b><b class="coin-glyph coin-glyph-bottom"></b><b class="coin-glyph coin-glyph-left"></b><i class="coin-hole"></i></span><span class="heart-coin-face back" aria-label="背面"><b class="coin-glyph coin-glyph-top"></b><b class="coin-glyph coin-glyph-bottom"></b><i class="coin-hole"></i></span></div><i class="heart-coin-ring"></i></div>
<div class="heart-coin" data-coin-index="1"><div class="heart-coin-inner"><span class="heart-coin-face front" aria-label="字面"><b class="coin-glyph coin-glyph-top"></b><b class="coin-glyph coin-glyph-right"></b><b class="coin-glyph coin-glyph-bottom"></b><b class="coin-glyph coin-glyph-left"></b><i class="coin-hole"></i></span><span class="heart-coin-face back" aria-label="背面"><b class="coin-glyph coin-glyph-top"></b><b class="coin-glyph coin-glyph-bottom"></b><i class="coin-hole"></i></span></div><i class="heart-coin-ring"></i></div>
<div class="heart-coin" data-coin-index="2"><div class="heart-coin-inner"><span class="heart-coin-face front" aria-label="字面"><b class="coin-glyph coin-glyph-top"></b><b class="coin-glyph coin-glyph-right"></b><b class="coin-glyph coin-glyph-bottom"></b><b class="coin-glyph coin-glyph-left"></b><i class="coin-hole"></i></span><span class="heart-coin-face back" aria-label="背面"><b class="coin-glyph coin-glyph-top"></b><b class="coin-glyph coin-glyph-bottom"></b><i class="coin-hole"></i></span></div><i class="heart-coin-ring"></i></div>
</div>
<h3 id="castingPrompt">心中默念所问之事,然后掷出初爻</h3>
<h3 id="castingPrompt" class="wt-serif">心中默念所问之事,然后掷出初爻</h3>
<button id="tossCoinsButton" class="heart-cast-button" type="button"><i class="heart-hold-charge" aria-hidden="true"></i><span>按住<br>摇初爻</span></button>
</section>
</div>
</div>
<div id="heartReveal" class="heart-stage">
<button class="button heart-return-button" type="button" data-heart-return><i data-lucide="arrow-left"></i><span>返回</span></button>
<button class="button heart-return-button" type="button" data-heart-return>← 返回</button>
<div class="heart-reveal-layout">
<section class="hexagram-board heart-reveal-board">
<div class="hexagram-heading">
@@ -740,18 +744,17 @@
</section>
<section class="heart-first-thought">
<span class="heart-stage-index">观心 · 四</span>
<h3>先不解卦</h3>
<h3 class="wt-serif">先不解卦</h3>
<p id="heartFirstThoughtPrompt">看见卦象与爻辞后,心里升起的第一念是什么?</p>
<p>不要修饰,也不必记录。只需看见它。</p>
<button id="interpretHeartButton" class="button primary" type="button">我已察念,开始解卦</button>
</section>
</div>
<div id="heartLineTexts" class="heart-line-texts" aria-label="六爻爻辞,悬停、点击或聚焦查看"></div>
</div>
<div id="heartInterpretationStage" class="heart-stage">
<div class="heart-interpretation-heading">
<div><span class="heart-stage-index">观心 · 五</span><h3 id="heartReadTitle">解卦</h3><small id="heartReadChange">--</small></div>
<div><span class="heart-stage-index">观心 · 五</span><h3 id="heartReadTitle" class="wt-serif">解卦</h3><small id="heartReadChange">--</small></div>
<div class="heart-read-actions"><button id="viewHeartReadingButton" class="button primary" type="button">查看解卦</button><button id="restartHeartButton" class="button" type="button">重新观心</button></div>
</div>
<p id="heartReadGuaci" class="heart-read-guaci">--</p>
@@ -759,7 +762,8 @@
<div id="heartReadLines" class="heart-read-lines"></div>
<div id="heartReadTexts" class="heart-read-texts"></div>
</div>
<p class="heart-read-motto">一念既察,卦只是镜。</p>
<p class="heart-read-motto wt-serif">一念既察,卦只是镜。</p>
</div>
</div>
<p class="heaven-footnote heart-footnote">观心用于观察念头与执着,不用于替代交易计划或预测涨跌。</p>
</section>
@@ -1305,8 +1309,8 @@
</div>
<div class="dragon-head-actions-v2">
<div class="dragon-view-tabs-v2" role="group" aria-label="龙虎榜视图">
<button class="active" type="button" aria-pressed="true">每日明细</button>
<button id="dragonProfilesButton" type="button" aria-pressed="false" title="游资档案暂未开放" disabled>游资档案</button>
<button id="dragonDailyButton" class="active" type="button" data-dragon-view-mode="daily" aria-pressed="true">每日明细</button>
<button id="dragonProfilesButton" type="button" data-dragon-view-mode="profiles" aria-pressed="false">游资档案</button>
</div>
<button id="dragonRefreshButton" class="button dragon-action-v2" type="button"><i data-lucide="refresh-cw"></i><span>刷新</span></button>
<button id="dragonExportButton" class="button dragon-action-v2" type="button"><i data-lucide="download"></i><span>导出 CSV</span></button>
@@ -1350,6 +1354,36 @@
<div id="unclassifiedSeatList" class="unclassified-seat-list"></div>
</details>
</div>
<section id="dragonProfilesContent" class="hot-money-profiles-v2" hidden aria-label="游资档案">
<header class="hot-money-profile-toolbar-v2">
<div class="dragon-filter-copy-v2">
<h3>游资名录</h3>
<span>公开收录的游资简介与关联营业部</span>
</div>
<div id="hotMoneyProfileSummary" class="hot-money-profile-summary-v2" aria-label="名录统计"></div>
<label class="dragon-search-v2 hot-money-profile-search-v2">
<i data-lucide="search" aria-hidden="true"></i>
<span class="visually-hidden">搜索游资档案</span>
<input id="hotMoneyProfileSearch" type="search" placeholder="搜索游资、简介或营业部" autocomplete="off">
</label>
</header>
<div class="hot-money-profile-workspace-v2">
<section class="hot-money-profile-directory-v2" aria-label="游资名录列表">
<header class="hot-money-profile-directory-head-v2">
<strong>全部游资</strong>
<span id="hotMoneyProfileResultCount">0 位</span>
</header>
<div id="hotMoneyProfileList" class="hot-money-profile-list-v2" role="listbox" aria-label="选择游资档案"></div>
</section>
<article id="hotMoneyProfileDetail" class="hot-money-profile-detail-v2" aria-live="polite">
<div class="hot-money-profile-empty-v2">
<i data-lucide="contact" aria-hidden="true"></i>
<strong>选择一位游资查看档案</strong>
</div>
</article>
</div>
</section>
</section>
<section id="reviewWorkspaceView" class="workspace-view page redesigned-review-view">
@@ -1466,7 +1500,7 @@
</form>
</dialog>
<dialog id="heavenReadingDialog" class="settings-dialog heaven-reading-dialog" aria-labelledby="heavenReadingDialogTitle">
<dialog id="heavenReadingDialog" class="settings-dialog heaven-reading-dialog wentian-v2-dialog" aria-labelledby="heavenReadingDialogTitle">
<div class="dialog-header">
<div><span id="heavenReadingEyebrow" class="dialog-eyebrow">问天 · 观势</span><h2 id="heavenReadingDialogTitle">解读</h2></div>
<button id="closeHeavenReadingDialog" class="icon-button" type="button" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
@@ -1786,6 +1820,7 @@
<form id="systemMarketForm" class="settings-section">
<div class="settings-section-heading"><h3>公共行情</h3><span id="systemDataStatus">待检查</span></div>
<label class="form-field"><span>Tushare Token</span><input id="systemTokenInput" type="password" autocomplete="off" minlength="20" placeholder="留空保留现有 Token"></label>
<label class="form-field"><span>iFinD Refresh Token</span><input id="systemIfindTokenInput" type="password" autocomplete="off" maxlength="2048" placeholder="留空保留现有 Token"></label>
<label class="switch-control"><input id="systemBackgroundRefresh" type="checkbox"><span>启用交易时段后台刷新</span></label>
<p class="form-hint">所有用户读取同一份后台快照,页面不会随后台任务自动重绘。</p>
<div class="dialog-actions admin-inline-actions"><button id="adminRefreshButton" class="button" type="button"><i data-lucide="refresh-cw"></i>立即后台刷新</button><button class="button primary" type="submit">保存行情配置</button></div>
@@ -1837,7 +1872,7 @@
<script src="/vendor/lucide.min.js" defer></script>
<script src="/ui-core.js" defer></script>
<script src="/heaven-loading.js" defer></script>
<script src="/app.js" defer></script>
<script src="/heaven-loading-v2.js?v=20260728-2" defer></script>
<script src="/app.js?v=20260728-2" defer></script>
</body>
</html>
+245 -1
View File
@@ -23,6 +23,30 @@
--r2-card: #fff;
--r2-radius: 10px;
--r2-shadow: 0 1px 2px rgba(16, 24, 40, .05);
/* Dragon profile component tokens. */
--dragon-profile-list-width: 340px;
--dragon-profile-detail-min-height: 460px;
--dragon-profile-list-max-height: 320px;
--dragon-profile-row-min-height: 64px;
--dragon-profile-row-avatar-size: 36px;
--dragon-profile-avatar-size: 72px;
--dragon-profile-control-height: 33px;
--dragon-profile-gap: 12px;
--dragon-profile-panel-padding: 16px;
--dragon-profile-row-padding: 10px 12px;
--dragon-profile-title-font: 18px;
--dragon-profile-name-font: 13px;
--dragon-profile-body-font: 12px;
--dragon-profile-meta-font: 11px;
--dragon-profile-transition: 160ms ease;
--dragon-profile-border-width: 1px;
--dragon-profile-focus-width: 2px;
--dragon-profile-focus-offset: -2px;
--dragon-profile-radius-inset: 2px;
--dragon-profile-body-line-height: 1.75;
--dragon-profile-icon-stroke: 1;
--dragon-profile-weight-strong: 750;
--dragon-profile-weight-semibold: 600;
}
html,
@@ -3582,7 +3606,7 @@ body.sidebar-collapsed .status-bar { left: 64px; }
overflow: hidden;
border: 1px solid var(--r2-line);
border-radius: var(--r2-radius);
background: #fff;
background: var(--r2-card);
box-shadow: var(--r2-shadow);
}
@@ -4483,6 +4507,226 @@ body.sidebar-collapsed .status-bar { left: 64px; }
.dragon-search-v2 { transition: none; }
}
/* Dragon profile directory: the master list is authoritative data, while the
detail pane only expands fields present in the public directory. */
.hot-money-profiles-v2 {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: var(--dragon-profile-gap);
overflow: hidden;
}
.hot-money-profiles-v2[hidden] { display: none; }
.hot-money-profile-toolbar-v2 {
min-height: calc(var(--dragon-profile-control-height) + var(--dragon-profile-gap));
display: flex;
align-items: center;
gap: var(--dragon-profile-gap);
padding: calc(var(--dragon-profile-gap) / 2) var(--dragon-profile-gap);
border: var(--dragon-profile-border-width) solid var(--r2-line);
border-radius: var(--r2-radius);
background: var(--r2-card);
box-shadow: var(--r2-shadow);
}
.hot-money-profile-toolbar-v2 .dragon-filter-copy-v2 { flex: 1 1 auto; }
.hot-money-profile-search-v2 { height: var(--dragon-profile-control-height); }
.hot-money-profile-summary-v2 {
display: flex;
align-items: center;
gap: var(--dragon-profile-gap);
}
.hot-money-profile-summary-v2 > span {
display: grid;
grid-template-columns: auto auto;
align-items: baseline;
gap: calc(var(--dragon-profile-gap) / 2);
white-space: nowrap;
}
.hot-money-profile-summary-v2 small {
color: var(--r2-faint);
font-size: var(--dragon-profile-meta-font);
}
.hot-money-profile-summary-v2 strong {
color: var(--r2-ink);
font-size: var(--dragon-profile-name-font);
font-variant-numeric: tabular-nums;
}
.hot-money-profile-workspace-v2 {
min-width: 0;
min-height: 0;
display: grid;
grid-template-columns: var(--dragon-profile-list-width) minmax(0, 1fr);
gap: var(--dragon-profile-gap);
}
.hot-money-profile-directory-v2,
.hot-money-profile-detail-v2 {
min-width: 0;
min-height: 0;
overflow: hidden;
border: var(--dragon-profile-border-width) solid var(--r2-line);
border-radius: var(--r2-radius);
background: var(--r2-card);
box-shadow: var(--r2-shadow);
}
.hot-money-profile-directory-v2 {
display: flex;
flex-direction: column;
}
.hot-money-profile-directory-head-v2 {
min-height: var(--dragon-profile-control-height);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--dragon-profile-gap);
border-bottom: var(--dragon-profile-border-width) solid var(--r2-line-soft);
}
.hot-money-profile-directory-head-v2 strong {
color: var(--r2-ink);
font-size: var(--dragon-profile-body-font);
}
.hot-money-profile-directory-head-v2 span {
color: var(--r2-faint);
font-size: var(--dragon-profile-meta-font);
font-variant-numeric: tabular-nums;
}
.hot-money-profile-list-v2 {
min-height: 0;
flex: 1 1 auto;
overflow-y: auto;
overscroll-behavior: contain;
}
.hot-money-profile-row-v2 {
width: 100%;
min-height: var(--dragon-profile-row-min-height);
display: grid;
grid-template-columns: auto var(--dragon-profile-row-avatar-size) minmax(0, 1fr) auto;
align-items: center;
gap: calc(var(--dragon-profile-gap) / 2);
padding: var(--dragon-profile-row-padding);
border: 0;
border-bottom: var(--dragon-profile-border-width) solid var(--r2-line-soft);
background: transparent;
text-align: left;
transition: background-color var(--dragon-profile-transition), color var(--dragon-profile-transition);
}
.hot-money-profile-row-v2:hover { background: var(--r2-bg); }
.hot-money-profile-row-v2.selected { background: var(--r2-blue-soft); }
.hot-money-profile-row-v2:focus-visible { outline: var(--dragon-profile-focus-width) solid var(--r2-blue); outline-offset: var(--dragon-profile-focus-offset); }
.hot-money-profile-index-v2 {
color: var(--r2-faint);
font-size: var(--dragon-profile-meta-font);
font-variant-numeric: tabular-nums;
}
.hot-money-profile-monogram-v2,
.hot-money-profile-avatar-v2 {
display: grid;
place-items: center;
border-radius: var(--r2-radius);
background: var(--r2-blue-soft);
color: var(--r2-blue);
font-weight: var(--dragon-profile-weight-strong);
}
.hot-money-profile-monogram-v2 {
width: var(--dragon-profile-row-avatar-size);
height: var(--dragon-profile-row-avatar-size);
font-size: var(--dragon-profile-meta-font);
}
.hot-money-profile-row-copy-v2 { min-width: 0; }
.hot-money-profile-row-copy-v2 strong,
.hot-money-profile-row-copy-v2 small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hot-money-profile-row-copy-v2 strong { color: var(--r2-ink); font-size: var(--dragon-profile-name-font); }
.hot-money-profile-row-copy-v2 small { margin-top: calc(var(--dragon-profile-gap) / 4); color: var(--r2-faint); font-size: var(--dragon-profile-meta-font); }
.hot-money-profile-seat-count-v2 {
color: var(--r2-sub);
font-size: var(--dragon-profile-meta-font);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.hot-money-profile-detail-v2 {
min-height: var(--dragon-profile-detail-min-height);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.hot-money-profile-detail-head-v2 {
display: flex;
align-items: center;
gap: var(--dragon-profile-gap);
padding: var(--dragon-profile-panel-padding);
border-bottom: var(--dragon-profile-border-width) solid var(--r2-line-soft);
}
.hot-money-profile-avatar-v2 {
width: var(--dragon-profile-avatar-size);
height: var(--dragon-profile-avatar-size);
flex: 0 0 var(--dragon-profile-avatar-size);
font-size: var(--dragon-profile-title-font);
}
.hot-money-profile-detail-head-v2 > div { min-width: 0; }
.hot-money-profile-detail-head-v2 small,
.hot-money-profile-detail-head-v2 span { display: block; color: var(--r2-faint); font-size: var(--dragon-profile-meta-font); }
.hot-money-profile-detail-head-v2 h3 { margin: calc(var(--dragon-profile-gap) / 3) 0; color: var(--r2-ink); font-size: var(--dragon-profile-title-font); }
.hot-money-profile-section-v2 { padding: var(--dragon-profile-panel-padding); border-bottom: var(--dragon-profile-border-width) solid var(--r2-line-soft); }
.hot-money-profile-section-v2 h4 { margin: 0 0 calc(var(--dragon-profile-gap) / 2); color: var(--r2-ink); font-size: var(--dragon-profile-name-font); }
.hot-money-profile-section-v2 p { margin: 0; color: var(--r2-sub); font-size: var(--dragon-profile-body-font); line-height: var(--dragon-profile-body-line-height); white-space: pre-line; }
.hot-money-profile-section-v2 p.is-empty { color: var(--r2-faint); }
.hot-money-profile-section-title-v2 { display: flex; align-items: center; justify-content: space-between; }
.hot-money-profile-section-title-v2 span { color: var(--r2-faint); font-size: var(--dragon-profile-meta-font); }
.hot-money-profile-organizations-v2 { display: grid; gap: calc(var(--dragon-profile-gap) / 2); }
.hot-money-profile-organizations-v2 > span {
min-height: var(--dragon-profile-control-height);
display: flex;
align-items: center;
gap: calc(var(--dragon-profile-gap) / 2);
padding: 0 var(--dragon-profile-gap);
border: var(--dragon-profile-border-width) solid var(--r2-line-soft);
border-radius: calc(var(--r2-radius) - var(--dragon-profile-radius-inset));
background: var(--r2-bg);
color: var(--r2-ink);
font-size: var(--dragon-profile-body-font);
}
.hot-money-profile-organizations-v2 .lucide { width: var(--dragon-profile-name-font); height: var(--dragon-profile-name-font); color: var(--r2-sub); }
.hot-money-profile-notice-v2 { margin: auto var(--dragon-profile-panel-padding) var(--dragon-profile-panel-padding); color: var(--r2-amber); font-size: var(--dragon-profile-meta-font); }
.hot-money-profile-empty-v2,
.hot-money-profile-list-empty-v2 {
min-height: var(--dragon-profile-detail-min-height);
display: grid;
place-items: center;
align-content: center;
gap: calc(var(--dragon-profile-gap) / 2);
color: var(--r2-faint);
text-align: center;
}
.hot-money-profile-list-empty-v2 { min-height: var(--dragon-profile-row-min-height); }
.hot-money-profile-empty-v2 .lucide,
.hot-money-profile-list-empty-v2 .lucide { width: var(--dragon-profile-avatar-size); height: var(--dragon-profile-avatar-size); stroke-width: var(--dragon-profile-icon-stroke); }
.hot-money-profile-list-empty-v2 .lucide { width: var(--dragon-profile-row-avatar-size); height: var(--dragon-profile-row-avatar-size); }
.hot-money-profile-empty-v2 strong,
.hot-money-profile-list-empty-v2 span { font-size: var(--dragon-profile-body-font); font-weight: var(--dragon-profile-weight-semibold); }
@media (min-width: 721px) {
body[data-active-view="dragonView"] .hot-money-profiles-v2 { flex: 1 1 auto; }
}
@media (max-width: 720px) {
.hot-money-profiles-v2 { overflow: visible; }
.hot-money-profile-toolbar-v2 { align-items: stretch; flex-direction: column; }
.hot-money-profile-summary-v2 { justify-content: space-between; }
.hot-money-profile-search-v2 { width: 100%; }
.hot-money-profile-workspace-v2 { grid-template-columns: minmax(0, 1fr); }
.hot-money-profile-list-v2 { max-height: var(--dragon-profile-list-max-height); }
.hot-money-profile-detail-v2 { min-height: 0; }
.hot-money-profile-detail-head-v2 { align-items: flex-start; }
}
@media (prefers-reduced-motion: reduce) {
.hot-money-profile-row-v2 { transition: none; }
}
/* Stage 15: screener rebuilt from the approved reference layout. */
#screenerView {
--scr-blue: #2563eb;
+1281
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+219 -7
View File
@@ -133,7 +133,13 @@ async function mockApplication(page, authSession = session(), options = {}) {
const url = new URL(route.request().url());
let payload = { ok: true };
if (url.pathname === "/api/auth/me") payload = authSession;
else if (url.pathname === "/api/dashboard") payload = dashboard;
else if (url.pathname === "/api/dashboard") {
options.dashboardRequests = (options.dashboardRequests || 0) + 1;
if (options.dashboardDelay) {
await new Promise((resolve) => setTimeout(resolve, options.dashboardDelay));
}
payload = dashboard;
}
else if (url.pathname === "/api/stock/002141/preview") {
payload = {
meta: { trade_date: "2026-07-23", intraday_trade_date: "2026-07-24", realtime: true, intraday_notice: "" },
@@ -233,6 +239,17 @@ async function mockApplication(page, authSession = session(), options = {}) {
items: [{ role: "assistant", content: "Review evidence before forming a conclusion.", context_date: "20260722" }],
};
} else if (url.pathname === "/api/search") payload = { groups: { stocks: [{ id: "002141", code: "002141", name: "Test Stock", type: "stock", industry: "Test Sector" }], sectors: [], themes: [], indices: [] } };
else if (url.pathname === "/api/dragon-tiger/profiles") {
payload = {
meta: { status: "success", source: "tushare", cached: true },
summary: { profile_count: 3, described_count: 2, organization_count: 4 },
profiles: [
{ id: "hot-money-profile-1", name: "赵老哥", description: "聚焦市场核心标的。", organizations: ["华泰证券浙江分公司", "银河证券绍兴"], organization_count: 2 },
{ id: "hot-money-profile-2", name: "炒股养家", description: "重视情绪与风险收益比。", organizations: ["华鑫证券上海宛平南路"], organization_count: 1 },
{ id: "hot-money-profile-3", name: "作手新一", description: "", organizations: ["国泰海通证券南京太平南路"], organization_count: 1 },
],
};
}
else if (url.pathname === "/api/dragon-tiger") {
payload = {
meta: { trade_date: "2026-07-22", requested_date: "2026-07-22", status: "empty", source: "tushare" },
@@ -336,15 +353,25 @@ async function mockApplication(page, authSession = session(), options = {}) {
},
],
};
if (options.additionalScreenerRegimes) {
payload.regimes.push(...options.additionalScreenerRegimes);
}
if (options.additionalScreenerStrategies) {
payload.strategies.push(...options.additionalScreenerStrategies);
}
if (options.latestScreenerResults) {
payload.latest_results = options.latestScreenerResults;
payload.latest_result = options.latestScreenerResults.smart || null;
}
if (options.recentScreenerResults) {
payload.recent_results = options.recentScreenerResults;
}
} else if (url.pathname === "/api/screener/run") {
const body = route.request().postDataJSON();
options.screenerRunBodies = [...(options.screenerRunBodies || []), body];
const runResult = options.screenerRunResult?.(body);
payload = {
result: options.latestScreenerResults?.[body.mode] || {
result: runResult || options.latestScreenerResults?.[body.mode] || {
meta: {
run_id: 99,
trade_date: "20260722",
@@ -357,6 +384,9 @@ async function mockApplication(page, authSession = session(), options = {}) {
backtest: null,
},
};
if (options.recentScreenerResults) {
options.recentScreenerResults.unshift(payload.result);
}
} else if (url.pathname === "/api/screener/tracking") {
if (route.request().method() === "POST") {
const body = route.request().postDataJSON();
@@ -446,6 +476,61 @@ test("admin shell opens every primary workspace and global search", async ({ pag
await expect(page.locator("#globalSearchInput")).toBeFocused();
});
test("manual refresh stays in place without reopening the full-page loader", async ({ page }) => {
const options = { dashboardDelay: 350 };
await mockApplication(page, session(), options);
await page.goto("/index.html");
await expect(page.locator("#loadingOverlay")).toBeHidden();
await page.locator("#refreshButton").click();
await expect(page.locator("#refreshButton")).toBeDisabled();
await expect(page.locator("#loadingOverlay")).toBeHidden();
await expect(page.locator("#statusText")).toContainText("刷新");
await expect(page.locator("#refreshButton")).toBeEnabled();
expect(options.dashboardRequests).toBe(2);
});
test("night mode covers the application shell and persists across reloads", async ({ page }) => {
await mockApplication(page, session("admin", true));
await page.addInitScript(() => {
if (sessionStorage.getItem("themeTestReady")) return;
localStorage.removeItem("xiaobaiTheme");
sessionStorage.setItem("themeTestReady", "1");
});
await page.goto("/index.html");
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-pressed", "false");
await page.locator("#themeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-label", "切换到日间模式");
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-pressed", "true");
const darkSurfaces = await page.evaluate(() => {
const color = (selector) => getComputedStyle(document.querySelector(selector)).backgroundColor;
return {
body: color("body"),
sidebar: color(".sidebar"),
topbar: color(".topbar"),
tableHead: color("#limitTable thead th"),
};
});
expect(new Set(Object.values(darkSurfaces)).has("rgb(255, 255, 255)")).toBe(false);
await page.reload();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-pressed", "true");
await page.keyboard.press("Control+K");
await expect(page.locator("#globalSearchDialog")).toBeVisible();
expect(await page.locator("#globalSearchDialog").evaluate((dialog) => getComputedStyle(dialog).backgroundColor)).not.toBe("rgb(255, 255, 255)");
await page.locator("#closeGlobalSearch").click();
await page.locator("#themeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-label", "切换到夜间模式");
});
test("collapsed overview and sentiment decision layout keep a single current reading", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("user", true));
@@ -1241,7 +1326,32 @@ test("dragon-tiger redesign keeps the merged empty state and independent card hi
descriptionSize: 13,
});
await page.locator("#dragonProfilesButton").click();
await expect(page.locator("#dragonProfilesContent")).toBeVisible();
await expect(page.locator("#dragonDailyContent")).toBeHidden();
await expect(page.locator("#hotMoneyProfileSummary > span")).toHaveCount(3);
await expect(page.locator("#hotMoneyProfileList .hot-money-profile-row-v2")).toHaveCount(3);
await expect(page.locator("#hotMoneyProfileDetail")).toContainText("赵老哥");
await expect(page.locator("#hotMoneyProfileDetail")).toContainText("华泰证券浙江分公司");
await page.locator("#hotMoneyProfileSearch").fill("宛平南路");
await expect(page.locator("#hotMoneyProfileList .hot-money-profile-row-v2")).toHaveCount(1);
await expect(page.locator("#hotMoneyProfileDetail")).toContainText("炒股养家");
await page.locator("#hotMoneyProfileSearch").fill("");
await page.locator('[data-hot-money-profile="hot-money-profile-3"]').click();
await expect(page.locator("#hotMoneyProfileDetail")).toContainText("名录暂未收录该游资的公开简介");
await page.setViewportSize({ width: 390, height: 844 });
const profileMobile = await page.evaluate(() => {
const list = document.querySelector("#hotMoneyProfileList").getBoundingClientRect();
const detail = document.querySelector("#hotMoneyProfileDetail").getBoundingClientRect();
return {
pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1,
detailBelowList: detail.top >= list.bottom - 1,
};
});
expect(profileMobile).toEqual({ pageFits: true, detailBelowList: true });
await page.locator("#dragonDailyButton").click();
const mobile = await page.evaluate(() => ({
pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1,
operationsScroll: document.querySelector("#dragonTraderDetail .trader-operations").scrollWidth > document.querySelector("#dragonTraderDetail .trader-operations").clientWidth + 1,
@@ -1954,6 +2064,93 @@ test("screener stage completion follows its execution context and mode results s
await expect(page.locator("#screenerTableBody")).not.toContainText("量化结果");
});
test("screener keeps results for each stage and curated strategy across switching and reload", async ({ page }) => {
const formula = {
meta: { library: "smart" }, universe: {}, filters: [],
score: [{ field: "relative_strength", weight: 1, direction: "desc" }],
limit: 10, min_score: 0.5,
};
const options = {
recentScreenerResults: [],
additionalScreenerRegimes: [{ id: "retreat", label: "Retreat" }],
additionalScreenerStrategies: [
{
id: 3, name: "Retreat Defense", description: "Retreat-stage strategy",
regimes: ["retreat"], builtin: true, data_ready: true, missing_data: [], formula,
},
{
id: 4, name: "Quality B", description: "Second curated strategy",
regimes: ["repair"], builtin: true, data_ready: true, missing_data: [],
formula: {
...formula,
meta: { library: "curated", category: "Quality", quality: "A", frequency: "Monthly", risk: "Low" },
},
},
],
};
options.screenerRunResult = (body) => {
const candidateName = body.mode === "smart"
? body.regime === "retreat" ? "Smart Retreat" : "Smart Repair"
: body.strategy_name === "Quality B" ? "Curated B" : "Curated A";
return {
meta: {
run_id: 100 + options.recentScreenerResults.length,
trade_date: "20260722",
regime: body.regime,
strategy_name: body.strategy_name,
mode: body.mode,
},
candidates: [{
code: `60000${options.recentScreenerResults.length + 1}`,
name: candidateName,
sector: "Test Sector",
score_display: 80,
historical_probability: 50,
probability_samples: 20,
pct_chg: 1,
return_5d: 2,
volume_ratio_5d: 1.2,
sector_strength: 70,
reason: "Context result",
risk_flags: [],
}],
disclaimer: "Historical statistics do not predict future returns.",
backtest: null,
};
};
await mockApplication(page, session("user", true), options);
await page.goto("/index.html?view=screenerView");
await page.locator("#screenerRunButton").click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
await page.locator('[data-regime="retreat"]').click();
await page.locator("#screenerRunButton").click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat");
await page.locator('[data-regime="repair"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
await page.locator('[data-regime="retreat"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat");
await page.locator('[data-screener-mode="curated"]').click();
await page.locator('[data-curated-run="2"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
await page.locator('[data-curated-run="4"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated B");
await page.locator('[data-curated-strategy="2"] .curated-card-description').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
await page.reload();
await page.locator('[data-screener-mode="smart"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
await page.locator('[data-regime="retreat"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat");
await page.locator('[data-screener-mode="curated"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
await page.locator('[data-curated-strategy="4"] .curated-card-description').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated B");
});
test("screener tracking is an internal page populated only by manual candidate actions", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("user", true));
@@ -2048,8 +2245,8 @@ test("heaven workspace actions remain compact and do not overlap", async ({ page
await page.goto("/index.html");
await page.locator('[data-view="heavenView"]').first().click();
const titleSize = Number.parseFloat(await page.locator("#heavenView .heaven-toolbar h2").evaluate((element) => getComputedStyle(element).fontSize));
expect(titleSize).toBeLessThanOrEqual(22);
const titleSize = Number.parseFloat(await page.locator("#heavenView .wt-title-line h1").evaluate((element) => getComputedStyle(element).fontSize));
expect(titleSize).toBeLessThanOrEqual(32);
const trendButtons = page.locator(".heaven-trend-actions .button");
await expect(trendButtons).toHaveCount(3);
@@ -2075,7 +2272,8 @@ test("heaven workspace actions remain compact and do not overlap", async ({ page
openHeavenReading("fortune", { loading: false });
});
const readingDialog = await page.locator("#heavenReadingDialog").boundingBox();
expect(readingDialog.width).toBeLessThanOrEqual(920);
expect(readingDialog.width).toBeLessThanOrEqual(1120);
expect(readingDialog.width / readingDialog.height).toBeGreaterThan(1.4);
expect(readingDialog.height).toBeLessThanOrEqual(820);
const readingHeader = await page.locator("#heavenReadingDialog .dialog-header").boundingBox();
const readingTabs = await page.locator("#heavenReadingDialog .heaven-reading-tabs").boundingBox();
@@ -2113,7 +2311,9 @@ test("heaven workspace controls fit a narrow viewport", async ({ page }) => {
expect(Math.abs(firstFortuneButton.y - secondFortuneButton.y)).toBeLessThanOrEqual(1);
await page.locator('[data-heaven-panel="heart"]').click();
const heartControls = await page.locator(".heart-toolbar-controls").boundingBox();
expect(heartControls.width).toBeLessThanOrEqual(80);
const heartPanel = await page.locator("#heavenHeartPanel").boundingBox();
expect(heartControls.width).toBeLessThanOrEqual(heartPanel.width);
expect(heartControls.x).toBeGreaterThanOrEqual(heartPanel.x - 1);
expect(heartControls.x + heartControls.width).toBeLessThanOrEqual(375);
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
});
@@ -2128,7 +2328,7 @@ test("mentor directory exposes evidence filters and private owner metadata", asy
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(22);
await expect(page.locator('#mentorList [data-mentor-id="private-owner"] .mentor-badge.private')).toContainText("仅自己");
await expect(page.locator('#mentorList [data-mentor-id="source-a"] .mentor-badge.grade-a')).toHaveText("A");
await expect(page.locator('#mentorList [data-mentor-id="source-c"] .mentor-badge.quality')).toHaveText("5/6");
await expect(page.locator('#mentorList [data-mentor-id="source-c"] .mentor-badge.quality')).toHaveCount(0);
await page.locator("#mentorSearchInput").fill("行为推演");
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(1);
@@ -2183,6 +2383,18 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
await expect(answer.locator(".mentor-answer-list li")).toHaveCount(2);
await expect(answer.locator("br")).toHaveCount(0);
await expect(page.locator("#mentorMessages .assistant-stream-caret")).toHaveCount(0);
await page.locator("#themeToggle").click();
const darkMessageStyle = await answer.evaluate((element) => {
const style = getComputedStyle(element);
return {
background: style.backgroundColor,
border: style.borderTopColor,
shadow: style.boxShadow,
};
});
expect(darkMessageStyle.background).not.toBe("rgb(255, 255, 255)");
expect(darkMessageStyle.border).not.toBe("rgb(255, 255, 255)");
expect(darkMessageStyle.shadow).toBe("none");
});
test("mobile mentor directory opens as a searchable selector and hides private mentors", async ({ page }) => {
+42
View File
@@ -79,6 +79,48 @@ class AccountDataBoundaryTests(unittest.TestCase):
self.database.latest_screener_runs(self.second["id"], "20260722"), {}
)
def test_latest_screener_context_runs_keep_each_stage_and_strategy(self):
runs = [
("smart", "repair", "Repair", "600001"),
("smart", "repair", "Repair", "600002"),
("smart", "retreat", "Retreat", "600003"),
("curated", "repair", "Dividend", "600004"),
("curated", "repair", "Momentum", "600005"),
("quant", "repair", "Custom quant", "600006"),
("quant", "repair", "Custom quant", "600007"),
]
for mode, regime, strategy, code in runs:
self.database.save_screener_run(
self.first["id"], "20260722", regime, strategy, FORMULA,
{"candidates": [{"code": code}], "meta": {}}, mode,
)
results = self.database.latest_screener_context_runs(
self.first["id"], "20260722"
)
by_context = {
(
item["meta"]["mode"],
item["meta"]["regime"] if item["meta"]["mode"] == "smart" else "",
item["meta"]["strategy_name"] if item["meta"]["mode"] != "quant" else "",
): item["candidates"][0]["code"]
for item in results
}
self.assertEqual(by_context, {
("smart", "repair", "Repair"): "600002",
("smart", "retreat", "Retreat"): "600003",
("curated", "", "Dividend"): "600004",
("curated", "", "Momentum"): "600005",
("quant", "", ""): "600007",
})
self.assertEqual(
self.database.latest_screener_context_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"
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
import copy
import unittest
from server import DashboardService
class SnapshotDatabase:
def __init__(self, snapshot, latest=None):
self.snapshot = snapshot
self.latest = latest
self.aliases = {}
def get_snapshot(self, _trade_date):
return copy.deepcopy(self.snapshot)
def reason_overrides(self, _trade_date):
return {}
def get_data_snapshot(self, kind, cache_key):
return copy.deepcopy(self.aliases.get((kind, cache_key)))
def save_data_snapshot(self, kind, cache_key, _source, payload):
self.aliases[(kind, cache_key)] = copy.deepcopy(payload)
def get_latest_real_snapshot(self, _trade_date, strictly_before=False):
return copy.deepcopy(self.latest)
class DashboardCacheTests(unittest.TestCase):
def service(self, snapshot):
service = object.__new__(DashboardService)
service.database = SnapshotDatabase(snapshot)
return service
def test_cached_dashboard_skips_sentiment_rebuild_when_fields_are_complete(self):
snapshot = {
"meta": {"source": "tushare", "trade_date": "2026-07-22"},
"overview": {
"sentiment_score": 32,
"sentiment_label": "weak",
"sentiment_phase": "retreat",
"sentiment_direction": "cooling",
"sentiment_components": {},
},
}
service = self.service(snapshot)
service._enrich_dashboard_sentiment = lambda *_args: self.fail(
"complete cached sentiment must not be rebuilt"
)
payload = service.get_dashboard("2026-07-22")
self.assertTrue(payload["meta"]["cached"])
self.assertEqual(payload["overview"]["sentiment_score"], 32)
def test_cached_dashboard_rebuilds_legacy_snapshot_missing_sentiment(self):
snapshot = {
"meta": {"source": "tushare", "trade_date": "2026-07-22"},
"overview": {"limit_up_count": 20},
}
service = self.service(snapshot)
calls = []
def enrich(payload, trade_date):
calls.append(trade_date)
payload["overview"].update({
"sentiment_score": 20,
"sentiment_label": "weak",
"sentiment_phase": "ice",
"sentiment_direction": "cooling",
"sentiment_components": {},
})
return payload
service._enrich_dashboard_sentiment = enrich
payload = service.get_dashboard("2026-07-22")
self.assertEqual(calls, ["20260722"])
self.assertEqual(payload["overview"]["sentiment_phase"], "ice")
def test_weekend_dashboard_reuses_latest_close_without_external_sync(self):
latest = {
"meta": {"source": "tushare", "trade_date": "2026-07-24"},
"overview": {
"sentiment_score": 32,
"sentiment_label": "weak",
"sentiment_phase": "retreat",
"sentiment_direction": "cooling",
"sentiment_components": {},
},
}
service = object.__new__(DashboardService)
service.database = SnapshotDatabase(None, latest)
service.sync_dashboard = lambda *_args: self.fail(
"weekend refresh must not call the external synchronization path"
)
first = service.get_dashboard("2026-07-25")
service.database.latest = None
second = service.get_dashboard("2026-07-25")
self.assertTrue(first["meta"]["carried_forward"])
self.assertEqual(first["meta"]["trade_date"], "2026-07-24")
self.assertEqual(second["meta"]["requested_date"], "2026-07-25")
if __name__ == "__main__":
unittest.main()
+46 -3
View File
@@ -24,6 +24,8 @@ class FrontendContractTests(unittest.TestCase):
cls.html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
cls.script = (STATIC_DIR / "app.js").read_text(encoding="utf-8")
cls.ui_core = (STATIC_DIR / "ui-core.js").read_text(encoding="utf-8")
cls.design_system = (STATIC_DIR / "design-system.css").read_text(encoding="utf-8")
cls.theme = (STATIC_DIR / "theme.css").read_text(encoding="utf-8")
collector = IdCollector()
collector.feed(cls.html)
cls.ids = collector.ids
@@ -62,6 +64,13 @@ class FrontendContractTests(unittest.TestCase):
):
self.assertIn(field, (STATIC_DIR.parent / "screener.py").read_text(encoding="utf-8"))
def test_wencai_workspace_is_not_exposed_and_mentor_hides_internal_quality_score(self):
self.assertNotIn('id="wencaiView"', self.html)
self.assertNotIn('data-view="wencaiView"', self.html)
for endpoint in ("/api/wencai", "/api/wencai/query", "/api/wencai/saved"):
self.assertNotIn(endpoint, self.script)
self.assertNotIn("${score}/${total}", self.script)
def test_auction_navigation_and_frontend_pools_follow_product_order(self):
rotation = self.html.index('data-view="rotationView"')
auction = self.html.index('data-view="auctionView"')
@@ -123,7 +132,7 @@ class FrontendContractTests(unittest.TestCase):
def test_shared_ui_core_loads_before_application(self):
self.assertLess(
self.html.index('<script src="/ui-core.js"'),
self.html.index('<script src="/app.js"'),
self.html.index('<script src="/app.js'),
)
for function_name in (
"number", "clamp", "escapeHtml", "formatNumber", "formatTimestamp",
@@ -158,7 +167,7 @@ class FrontendContractTests(unittest.TestCase):
self.assertIn("context.lineTo(x, bodyTop);", candle)
self.assertIn("context.moveTo(x, bodyBottom);", candle)
self.assertIn("context.lineTo(x, lowY);", candle)
self.assertIn("context.fillStyle = CHART_BACKGROUND;", candle)
self.assertIn("context.fillStyle = palette.background;", candle)
self.assertIn("context.strokeRect(bodyLeft, bodyTop, candleWidth, bodyHeight);", candle)
self.assertNotIn("context.lineTo(x, lowY);\n context.stroke();\n const openY", candle)
@@ -167,7 +176,7 @@ class FrontendContractTests(unittest.TestCase):
end = self.script.index("function drawDailyPreviewChart", start)
chart = self.script[start:end]
self.assertIn("point.average", chart)
self.assertIn('context.strokeStyle = "#b7791f";', chart)
self.assertIn("context.strokeStyle = palette.average;", chart)
self.assertIn('intraday_trade_date || payload.meta?.trade_date', self.script)
self.assertIn('(payload.intraday || []).length ? "最新分时 · 1分钟"', self.script)
@@ -197,6 +206,40 @@ class FrontendContractTests(unittest.TestCase):
self.assertIn('state.stockDetailChartMode === "intraday"', self.script)
self.assertIn('state.entityDetailChartMode === "intraday"', self.script)
def test_entity_daily_chart_uses_runtime_theme_palette(self):
start = self.script.index("function drawEntityDetailChart")
end = self.script.index("function clearEntityDetailChart", start)
chart = self.script[start:end]
self.assertIn("const palette = currentChartPalette();", chart)
self.assertIn("context.fillStyle = palette.background;", chart)
self.assertIn("context.fillStyle = palette.axis;", chart)
self.assertNotIn('context.fillStyle = "#6c7983";', chart)
def test_dark_mentor_tokens_and_sentiment_bottom_clearance_are_defined(self):
self.assertIn(":root[data-theme=\"dark\"] #mentorView {", self.theme)
self.assertIn("--mentor-ink: var(--text-primary);", self.theme)
self.assertIn("--mentor-sub: var(--text-secondary);", self.theme)
self.assertIn(":root[data-theme=\"dark\"] #mentorView .mentor-message {", self.theme)
self.assertIn("border-color: var(--line-soft);", self.theme)
self.assertIn("background: var(--surface-subtle);", self.theme)
self.assertIn("box-shadow: none;", self.theme)
self.assertIn("#sentimentCycleView .sentiment-history-frame {", self.theme)
self.assertIn("margin-bottom: var(--card-gap);", self.theme)
self.assertIn("padding-bottom: var(--card-gap);", self.theme)
self.assertIn("--sentiment-history-max-height:510px;", self.design_system)
self.assertIn("max-height:var(--sentiment-history-max-height);", self.design_system)
self.assertIn("overflow:auto;", self.design_system)
def test_theme_switch_is_atomic_and_theme_library_loading_surface_is_dark_safe(self):
self.assertIn('typeof document.startViewTransition === "function"', self.script)
self.assertIn('root.classList.add("theme-switching")', self.script)
self.assertIn('root.classList.remove("theme-switching")', self.script)
self.assertIn("clearThemeTransitionEffects();", self.script)
self.assertIn("redrawThemeSensitiveVisuals();", self.script)
self.assertIn(":root.theme-switching *", self.theme)
self.assertIn("::view-transition-old(root)", self.theme)
self.assertIn(".theme-detail-empty-v2,", self.theme)
def test_membership_copy_includes_review_assistant_access(self):
self.assertIn("复盘助手仅对会员开放", self.html)
self.assertIn("智能选股、问师、问天、复盘助手等智能功能", self.html)
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import unittest
from tushare_client import TushareClient
class HotMoneyProfileClient(TushareClient):
def query(self, api_name, params=None, fields=""):
self.last_request = (api_name, params or {}, fields)
if api_name != "hm_list":
raise AssertionError(f"unexpected api: {api_name}")
return [
{
"name": "赵老哥",
"desc": "聚焦市场核心标的。",
"orgs": "华泰证券浙江分公司;银河证券绍兴",
},
{
"name": "炒股养家",
"desc": "",
"orgs": "华鑫证券上海宛平南路, 华鑫证券上海分公司",
},
{
"name": "赵老哥",
"desc": "重复记录不应覆盖首条档案。",
"orgs": "重复席位",
},
{"name": "", "desc": "无效记录", "orgs": ""},
]
class HotMoneyProfileTests(unittest.TestCase):
def test_directory_normalizes_profiles_and_organizations(self):
client = HotMoneyProfileClient("token")
payload = client.hot_money_profiles()
self.assertEqual(client.last_request[0], "hm_list")
self.assertEqual(client.last_request[2], "name,desc,orgs")
self.assertEqual(payload["meta"]["status"], "success")
self.assertEqual(payload["summary"], {
"profile_count": 2,
"described_count": 1,
"organization_count": 4,
})
self.assertEqual(
payload["profiles"][0]["organizations"],
["华泰证券浙江分公司", "银河证券绍兴"],
)
self.assertEqual(payload["profiles"][1]["organization_count"], 2)
self.assertEqual(
[item["id"] for item in payload["profiles"]],
["hot-money-profile-1", "hot-money-profile-2"],
)
def test_directory_parses_json_encoded_organization_lists(self):
client = TushareClient("token")
client.query = lambda *_args, **_kwargs: [
{
"name": "Profile",
"desc": "",
"orgs": '["Seat A", "Seat B", "Seat A"]',
}
]
payload = client.hot_money_profiles()
self.assertEqual(payload["profiles"][0]["organizations"], ["Seat A", "Seat B"])
self.assertEqual(payload["summary"]["organization_count"], 2)
if __name__ == "__main__":
unittest.main()
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import unittest
from ifind_client import IfindError, IfindHttpClient
class IfindClientTests(unittest.TestCase):
def test_table_rows_normalizes_single_table_payload(self):
rows = IfindHttpClient._table_rows(
{
"tables": {
"thscode": "300033.SZ",
"time": ["2026-07-28 09:30", "2026-07-28 09:31"],
"table": {"close": [10.1, 10.2], "amount": [100, 200]},
}
}
)
self.assertEqual(len(rows), 2)
self.assertEqual(rows[0]["thscode"], "300033.SZ")
self.assertEqual(rows[1]["time"], "2026-07-28 09:31")
self.assertEqual(rows[1]["close"], 10.2)
def test_table_rows_normalizes_wencai_list_payload(self):
rows = IfindHttpClient._table_rows(
{
"tables": [
{
"table": {
"股票代码": ["000001.SZ", "600000.SH"],
"股票简称": ["平安银行", "浦发银行"],
}
}
]
}
)
self.assertEqual([row["股票代码"] for row in rows], ["000001.SZ", "600000.SH"])
def test_display_date_rejects_invalid_values(self):
self.assertEqual(IfindHttpClient._display_date("20260728"), "2026-07-28")
with self.assertRaises(IfindError):
IfindHttpClient._display_date("2026-7-28")
def test_client_requires_credentials_before_request(self):
client = IfindHttpClient()
self.assertFalse(client.configured)
with self.assertRaises(IfindError):
client.real_time("000001.SH", ["latest"])
if __name__ == "__main__":
unittest.main()
+154
View File
@@ -0,0 +1,154 @@
from __future__ import annotations
import tempfile
import unittest
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from chart_data_provider import EastmoneyChartClient, MarketChartClient
from database import ReviewDatabase
from market_insights import MarketInsightsService
from server import DashboardService
class FakeIfind:
configured = True
def history(self, codes, indicators, start_date, end_date, cache_ttl=0):
return [
{
"time": "2026-07-27",
"thscode": "000001.SZ",
"open": 10,
"high": 10.5,
"low": 9.8,
"close": 10.2,
"volume": 100,
"amount": 1_000_000,
},
{
"time": "2026-07-28",
"thscode": "000001.SZ",
"open": 10.2,
"high": 10.8,
"low": 10.1,
"close": 10.5,
"volume": 120,
"amount": 1_200_000,
},
]
def real_time(self, codes, indicators, cache_ttl=0):
return []
class FakeIfindSnapshots:
configured = True
def __init__(self):
self.calls = []
def snapshots(self, codes, indicators, start_time, end_time, cache_ttl=0):
self.calls.append(
{
"codes": codes,
"indicators": indicators,
"start_time": start_time,
"end_time": end_time,
"cache_ttl": cache_ttl,
}
)
return [
{
"time": "2026-07-28 09:21:00",
"thscode": "000001.SZ",
"latest": 10.5,
"preClose": 10,
"volume": 2000,
"amount": 21000,
"bidSize1": 1200,
"askSize1": 800,
}
]
class FakeTushare:
pass
class IfindFeatureTests(unittest.TestCase):
def test_wencai_saved_queries_are_isolated_by_user(self):
with tempfile.TemporaryDirectory() as temporary:
database = ReviewDatabase(Path(temporary) / "review.db")
first = database.create_user("first-user", "salt", "hash")
second = database.create_user("second-user", "salt", "hash")
database.save_wencai_query(first["id"], "高质量", "ROE大于15%", "stock")
self.assertEqual(len(database.list_wencai_saved_queries(first["id"])), 1)
self.assertEqual(database.list_wencai_saved_queries(second["id"]), [])
def test_ifind_daily_chart_normalizes_change(self):
client = MarketChartClient(FakeIfind(), EastmoneyChartClient())
rows = client.stock_daily("000001", "20260728")
self.assertEqual(rows[-1]["trade_date"], "2026-07-28")
self.assertAlmostEqual(rows[-1]["change"], 2.9412, places=4)
def test_event_enrichment_keeps_blank_broken_reason_blank(self):
dashboard = {"broken": [{"code": "000001", "reason": "原原因"}]}
DashboardService._merge_ifind_event_enrichment(
dashboard,
{
"broken": {
"000001": {
"reason": "",
"first_time": "09:42:00",
"last_time": "",
"open_times": 3,
}
}
},
)
self.assertEqual(dashboard["broken"][0]["reason"], "原原因")
self.assertEqual(dashboard["broken"][0]["open_times"], 3)
def test_dynamic_auction_uses_ifind_snapshot_window_and_normalizes_rows(self):
with tempfile.TemporaryDirectory() as temporary:
database = ReviewDatabase(Path(temporary) / "review.db")
database.upsert_stock_master(
[
{
"ts_code": "000001.SZ",
"name": "Ping An Bank",
"industry": "Bank",
"market": "MainBoard",
"list_date": "19910403",
}
]
)
ifind = FakeIfindSnapshots()
service = MarketInsightsService(
database,
FakeTushare(),
now_provider=lambda: datetime(
2026, 7, 28, 9, 22, tzinfo=timezone(timedelta(hours=8))
),
ifind=ifind,
)
service._auction_candidates = lambda rows, baseline: (
[{"ts_code": "000001.SZ"}],
{},
[],
)
rows = service._dynamic_auction_rows("20260728", "20260727", 0)
self.assertEqual(ifind.calls[0]["start_time"], "2026-07-28 09:15:00")
self.assertEqual(ifind.calls[0]["end_time"], "2026-07-28 09:22:00")
self.assertEqual(rows[0]["ts_code"], "000001.SZ")
self.assertEqual(rows[0]["price"], 10.5)
self.assertEqual(rows[0]["snapshot_time"], "2026-07-28 09:21:00")
self.assertTrue(rows[0]["dynamic"])
if __name__ == "__main__":
unittest.main()
+52
View File
@@ -1326,6 +1326,58 @@ class TushareClient:
"methodology": "同花顺行业最新成分股的 rt_k 等权涨跌、宽度与成交额聚合",
}
def hot_money_profiles(self) -> dict[str, Any]:
rows = self.query("hm_list", {}, "name,desc,orgs")
profiles: list[dict[str, Any]] = []
seen_names: set[str] = set()
for row in rows:
name = str(row.get("name") or "").strip()
if not name or name in seen_names:
continue
seen_names.add(name)
description = _text(row.get("desc"))
organization_text = _text(row.get("orgs"))
parsed_organizations: Any = None
if organization_text.startswith("["):
try:
parsed_organizations = json.loads(organization_text)
except json.JSONDecodeError:
parsed_organizations = None
organization_parts = (
parsed_organizations
if isinstance(parsed_organizations, list)
else re.split(r"[,;\n]+", organization_text)
)
organizations = list(dict.fromkeys(
_text(part)
for part in organization_parts
if _text(part)
))
profiles.append(
{
"id": f"hot-money-profile-{len(profiles) + 1}",
"name": name,
"description": description,
"organizations": organizations,
"organization_count": len(organizations),
}
)
return {
"meta": {
"source": "tushare",
"status": "success" if profiles else "empty",
"schema_version": 1,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "",
},
"summary": {
"profile_count": len(profiles),
"described_count": sum(bool(item["description"]) for item in profiles),
"organization_count": sum(item["organization_count"] for item in profiles),
},
"profiles": profiles,
}
def dragon_tiger(self, requested_date: str) -> dict[str, Any]:
trade_date, _ = self.resolve_trade_context(requested_date)
detail_rows = self.query(