from __future__ import annotations

import argparse
import copy
import json
import mimetypes
import re
import secrets
import threading
import time
from datetime import date, datetime, time as dt_time, timedelta, timezone
from http import HTTPStatus
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import parse_qs, unquote, urlparse

from assistant_agent import ReviewAssistantError, stream_review_assistant
from api_access import ROUTES
from backend.bootstrap import build_application_container, load_runtime_settings
from backend.http import correlation_id, normalize_error_payload
from backend.llm import LLMGateway, LLMGatewayError
from chart_data_provider import ChartDataError
from app_config import (
    DATA_DIR,
    MENTOR_SKILLS_DIR,
    PRIVATE_MENTOR_SKILLS_DIR,
    SESSION_COOKIE,
    SESSION_MAX_AGE,
    STATIC_DIR,
    TOKEN_PATTERN,
    USERNAME_PATTERN,
    add_months as _add_months,
    membership_boundary as _membership_boundary,
    normalize_date,
    parse_iso_datetime as _parse_iso_datetime,
    tushare_code,
    validate_stock_code,
    validate_text,
)
from database import ReviewDatabase
from heaven_agent import HeavenAgentError, interpret_heaven
from heaven_engine import (
    _market_line_scores,
    _score_to_line,
    build_five_phase_field,
    build_market_hexagram,
    build_personal_field,
    hexagram_from_lines,
)
from ifind_client import IfindError
from llm_strategy import LLMCompilerError, compile_strategy_with_llm, test_llm_connection
from mentor_agent import MentorAgentError, stream_with_mentor
from market_insights import MarketInsightsService
from screener import (
    FACTOR_FIELDS,
    FACTOR_GROUPS,
    REGIMES,
    FactorDataService,
    compile_local_strategy,
)
from security import SecretVault, hash_password, token_hash, verify_password
from sentiment_engine import (
    COMPONENT_WEIGHTS,
    SENTIMENT_ENGINE_VERSION,
    apply_sentiment_to_dashboard,
    build_sentiment_history,
    latest_contiguous_history,
)
from tushare_client import TushareClient, TushareError, _sector_coverage_issue


SCREENER_LIBRARY_VERSION = 8


def automatic_screener_jobs(
    strategies: list[dict[str, Any]], regime_id: str
) -> list[dict[str, Any]]:
    """Build the close-of-day jobs; only stage screening is regime-gated."""
    smart_strategy = next(
        (
            item for item in strategies
            if item.get("formula", {}).get("meta", {}).get("library") != "curated"
            and regime_id in (item.get("regimes") or [])
        ),
        None,
    )
    curated = [
        item for item in strategies
        if item.get("formula", {}).get("meta", {}).get("library") == "curated"
    ]
    jobs = ([{"mode": "smart", "strategy": smart_strategy}] if smart_strategy else [])
    jobs.extend({"mode": "curated", "strategy": item} for item in curated)
    return jobs


LEGACY_SECRET_KEYS = {
    "TUSHARE_TOKEN",
    "IFIND_REFRESH_TOKEN",
    "IFIND_ACCESS_TOKEN",
    "LLM_API_KEY",
    "LLM_BASE_URL",
    "LLM_MODEL",
    "LLM_PRIMARY_API_KEY",
    "LLM_PRIMARY_BASE_URL",
    "LLM_PRIMARY_MODEL",
    "LLM_FALLBACK_API_KEY",
    "LLM_FALLBACK_BASE_URL",
    "LLM_FALLBACK_MODEL",
}

SEARCH_INDEXES = (
    {"id": "000001.SH", "code": "000001.SH", "name": "上证指数", "type": "index", "subtitle": "沪市综合指数"},
    {"id": "399001.SZ", "code": "399001.SZ", "name": "深证成指", "type": "index", "subtitle": "深市成份指数"},
    {"id": "399006.SZ", "code": "399006.SZ", "name": "创业板指", "type": "index", "subtitle": "创业板核心指数"},
)
SEARCH_TYPE_LABELS = {
    "stock": "股票",
    "sector": "板块",
    "theme": "题材",
    "index": "指数",
}
THS_SEARCH_TYPES = {
    "I": ("sector", "行业板块"),
    "R": ("sector", "地域板块"),
    "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:
        runtime = load_runtime_settings()
        self.vault = SecretVault(runtime.encryption_key)
        self.database = ReviewDatabase(DATA_DIR / "review.db")
        self.sync_lock = threading.Lock()
        self.auth_lock = threading.Lock()
        self.system_lock = threading.Lock()
        self.auto_screener_lock = threading.Lock()
        self._auto_screener_last_attempt: dict[str, datetime] = {}
        self._ifind_event_lock = threading.Lock()
        self._request_context = threading.local()
        self._system_credentials = self._load_system_credentials(runtime.initial_credentials)
        self.container = build_application_container(
            self.database,
            self._system_credentials,
            MENTOR_SKILLS_DIR,
            PRIVATE_MENTOR_SKILLS_DIR,
            lambda: self.token,
        )
        self.data_gateway = self.container.data_gateway
        self.ifind = self.container.ifind
        self.screener = self.container.screener
        self.strategy_tracking = self.container.strategy_tracking
        self.alert_service = self.container.alert_service
        self.trade_journal = self.container.trade_journal
        self.mentor_skills = self.container.mentor_skills
        self.realtime_aggregator = self.container.realtime_aggregator
        self.chart_data = self.container.chart_data
        self.jobs = self.container.jobs
        self.llm_gateway = LLMGateway(
            database=self.database,
            user_id_supplier=lambda: self.current_user_id,
            membership_supplier=self.membership,
            settings_supplier=lambda: self._system_credentials,
            profile_supplier=self._resolved_llm_profile,
        )
        self.screener.ensure_builtin_strategies()
        self._background_stop = threading.Event()
        self._background_thread = self.jobs.start_scheduler(
            self._background_refresh_tick,
            self._background_stop,
            interval_seconds=5,
            initial_delay_seconds=3,
        )

    def _tushare_client(self) -> TushareClient:
        gateway = getattr(self, "data_gateway", None)
        if gateway is not None:
            return gateway.tushare()
        # Compatibility for isolated legacy unit-test service stubs.
        return TushareClient(self.token)

    def _load_system_credentials(self, environment: dict[str, str]) -> dict[str, Any]:
        encrypted = self.database.get_system_setting("credentials")
        current = self.vault.decrypt_json(encrypted) if encrypted else {}
        changed = False
        first_user_id = self.database.first_user_id()
        first_personal: dict[str, Any] = {}
        if first_user_id:
            first_encrypted = self.database.get_user_credentials(first_user_id)
            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 "",
            "platform_llm_fallback_api_key": environment.get("platform_llm_fallback_api_key") or first_personal.get("llm_fallback_api_key") or "",
            "platform_llm_fallback_base_url": environment.get("platform_llm_fallback_base_url") or first_personal.get("llm_fallback_base_url") or "",
            "platform_llm_fallback_model": environment.get("platform_llm_fallback_model") or first_personal.get("llm_fallback_model") or "",
            "member_daily_limit": 50,
            "background_refresh_enabled": True,
        }
        for key, value in defaults.items():
            if key not in current:
                current[key] = value
                changed = True
        if not isinstance(current.get("llm_models"), list):
            migrated_models: list[dict[str, str]] = []
            for role, label in (("primary", "原主模型"), ("fallback", "原辅助模型")):
                profile = {
                    "api_key": str(current.get(f"platform_llm_{role}_api_key") or ""),
                    "base_url": str(current.get(f"platform_llm_{role}_base_url") or ""),
                    "model": str(current.get(f"platform_llm_{role}_model") or ""),
                }
                if profile["api_key"] or profile["model"]:
                    model_id = f"migrated-{role}"
                    migrated_models.append(
                        {"id": model_id, "name": label, **profile}
                    )
                    current[f"{role}_model_id"] = model_id
            current["llm_models"] = migrated_models
            current.setdefault("primary_model_id", "")
            current.setdefault("fallback_model_id", "")
            changed = True
        if changed or not encrypted:
            self.database.save_system_setting("credentials", self.vault.encrypt_json(current))
        for row in self.database.list_user_credentials():
            personal = self.vault.decrypt_json(str(row.get("encrypted_payload") or ""))
            if "tushare_token" in personal:
                personal.pop("tushare_token", None)
                self.database.save_user_credentials(
                    int(row["user_id"]), self.vault.encrypt_json(personal)
                )
        return current

    def _save_system_credentials(self, credentials: dict[str, Any]) -> None:
        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:
        return bool(self.token)

    def bind_user(self, user_id: int) -> None:
        self._request_context.user_id = int(user_id)
        encrypted = self.database.get_user_credentials(int(user_id))
        self._request_context.credentials = self.vault.decrypt_json(encrypted) if encrypted else {}
        self._request_context.access = self.database.user_access(int(user_id)) or {}

    @property
    def current_user_id(self) -> int:
        user_id = getattr(self._request_context, "user_id", 0)
        if not user_id:
            raise ValueError("当前请求尚未绑定账号。")
        return int(user_id)

    def _credentials(self) -> dict[str, str]:
        credentials = getattr(self._request_context, "credentials", {})
        return {
            "llm_primary_api_key": str(credentials.get("llm_primary_api_key") or ""),
            "llm_primary_base_url": str(
                credentials.get("llm_primary_base_url") or "https://api.openai.com/v1"
            ),
            "llm_primary_model": str(credentials.get("llm_primary_model") or ""),
            "llm_fallback_api_key": str(credentials.get("llm_fallback_api_key") or ""),
            "llm_fallback_base_url": str(credentials.get("llm_fallback_base_url") or ""),
            "llm_fallback_model": str(credentials.get("llm_fallback_model") or ""),
        }

    def _save_credentials(self, credentials: dict[str, str]) -> None:
        self.database.save_user_credentials(
            self.current_user_id,
            self.vault.encrypt_json(credentials),
        )
        self._request_context.credentials = dict(credentials)

    @property
    def token(self) -> str:
        return str(self._system_credentials.get("tushare_token") or "")

    def _personal_llm_profile(self) -> dict[str, Any]:
        credentials = self._credentials()
        return {
            "source": "personal",
            "primary": {
                "api_key": credentials["llm_primary_api_key"],
                "base_url": credentials["llm_primary_base_url"],
                "model": credentials["llm_primary_model"],
            },
            "fallback": {
                "api_key": credentials["llm_fallback_api_key"],
                "base_url": credentials["llm_fallback_base_url"],
                "model": credentials["llm_fallback_model"],
            },
        }

    def _platform_llm_profile(self) -> dict[str, Any]:
        models = {
            str(item.get("id") or ""): item
            for item in self._system_credentials.get("llm_models") or []
            if isinstance(item, dict) and item.get("id")
        }

        def selected(role: str) -> dict[str, str]:
            item = models.get(str(self._system_credentials.get(f"{role}_model_id") or ""), {})
            return {
                "id": str(item.get("id") or ""),
                "name": str(item.get("name") or ""),
                "api_key": str(item.get("api_key") or ""),
                "base_url": str(item.get("base_url") or ""),
                "model": str(item.get("model") or ""),
            }

        return {
            "source": "platform",
            "primary": selected("primary"),
            "fallback": selected("fallback"),
        }

    @staticmethod
    def _profile_configured(profile: dict[str, str]) -> bool:
        return bool(profile.get("api_key") and profile.get("base_url") and profile.get("model"))

    def membership(self) -> dict[str, Any]:
        access = getattr(self._request_context, "access", {}) or self.database.user_access(self.current_user_id) or {}
        now = datetime.now(timezone.utc)
        starts = _parse_iso_datetime(access.get("membership_starts_at"))
        expires = _parse_iso_datetime(access.get("membership_expires_at"))
        subscribed = (
            access.get("membership_status") == "active"
            and (not starts or starts <= now)
            and (not expires or expires > now)
        )
        is_admin = str(access.get("role")) == "admin"
        active = is_admin or subscribed
        remaining_seconds = None
        if expires:
            remaining_seconds = max(0, int((expires - now).total_seconds()))
        return {
            "active": active,
            "subscribed": subscribed,
            "status": "active" if subscribed else str(access.get("membership_status") or "inactive"),
            "plan": str(access.get("membership_plan") or ""),
            "starts_at": str(access.get("membership_starts_at") or ""),
            "expires_at": str(access.get("membership_expires_at") or ""),
            "is_admin": is_admin,
            "remaining_seconds": remaining_seconds,
            "remaining_days": None if remaining_seconds is None else (remaining_seconds + 86399) // 86400,
        }

    def _resolved_llm_profile(self) -> dict[str, Any]:
        platform = self._platform_llm_profile()
        platform_ready = self.membership()["active"] and self._profile_configured(platform["primary"])
        if platform_ready:
            return platform
        return {"source": "none", "primary": {}, "fallback": {}}

    @property
    def llm_primary_api_key(self) -> str:
        return str(self._resolved_llm_profile()["primary"].get("api_key") or "")

    @property
    def llm_primary_base_url(self) -> str:
        return str(self._resolved_llm_profile()["primary"].get("base_url") or "")

    @property
    def llm_primary_model(self) -> str:
        return str(self._resolved_llm_profile()["primary"].get("model") or "")

    @property
    def llm_fallback_api_key(self) -> str:
        return str(self._resolved_llm_profile()["fallback"].get("api_key") or "")

    @property
    def llm_fallback_base_url(self) -> str:
        return str(self._resolved_llm_profile()["fallback"].get("base_url") or "")

    @property
    def llm_fallback_model(self) -> str:
        return str(self._resolved_llm_profile()["fallback"].get("model") or "")

    @property
    def llm_source(self) -> str:
        return str(self._resolved_llm_profile().get("source") or "none")

    @property
    def llm_configured(self) -> bool:
        return bool(self.llm_primary_api_key and self.llm_primary_model)

    @property
    def llm_fallback_configured(self) -> bool:
        return bool(
            self.llm_fallback_api_key
            and self.llm_fallback_base_url
            and self.llm_fallback_model
        )

    def save_llm_settings(
        self,
        primary: dict[str, Any],
        fallback: dict[str, Any],
        fallback_enabled: bool,
    ) -> None:
        personal = self._personal_llm_profile()
        primary_profile = self._validate_llm_profile(
            primary,
            personal["primary"],
            required=True,
            label="主模型",
        )
        if fallback_enabled:
            fallback_profile = self._validate_llm_profile(
                fallback,
                personal["fallback"],
                required=True,
                label="辅助模型",
            )
        else:
            fallback_profile = {"api_key": "", "base_url": "", "model": ""}
        credentials = self._credentials()
        credentials.update(
            {
                "llm_primary_api_key": primary_profile["api_key"],
                "llm_primary_base_url": primary_profile["base_url"],
                "llm_primary_model": primary_profile["model"],
                "llm_fallback_api_key": fallback_profile["api_key"],
                "llm_fallback_base_url": fallback_profile["base_url"],
                "llm_fallback_model": fallback_profile["model"],
            }
        )
        self._save_credentials(credentials)

    def save_llm_mode(self, mode: str) -> None:
        raise ValueError("LLM 算力由管理员统一配置，会员账号自动使用平台模型。")

    def test_llm_profile(self, role: str, payload: dict[str, Any]) -> dict[str, Any]:
        personal = self._personal_llm_profile()
        if role == "primary":
            current = personal["primary"]
            label = "主模型"
        elif role == "fallback":
            current = personal["fallback"]
            label = "辅助模型"
        else:
            raise ValueError("模型角色不支持。")
        profile = self._validate_llm_profile(payload, current, required=True, label=label)
        try:
            return self.llm_gateway.probe(
                profile,
                lambda model: test_llm_connection(
                    model.api_key, model.base_url, model.model
                ),
            )
        except LLMCompilerError as exc:
            raise ValueError(str(exc)) from exc

    @staticmethod
    def _validate_llm_profile(
        payload: dict[str, Any],
        current: dict[str, str],
        required: bool,
        label: str,
    ) -> dict[str, str]:
        api_key = str(payload.get("api_key") or current.get("api_key") or "").strip()
        base_url = str(payload.get("base_url") or current.get("base_url") or "").strip().rstrip("/")
        model = str(payload.get("model") or current.get("model") or "").strip()
        if not required and not any((api_key, base_url, model)):
            return {"api_key": "", "base_url": "", "model": ""}
        parsed = urlparse(base_url)
        if parsed.scheme not in {"http", "https"} or not parsed.netloc:
            raise ValueError(f"{label} Base URL 格式不正确。")
        if not api_key or len(api_key) > 300:
            raise ValueError(f"{label} API Key 不能为空或过长。")
        if not model or len(model) > 100:
            raise ValueError(f"{label}模型名称不能为空或过长。")
        return {"api_key": api_key, "base_url": base_url, "model": model}

    def llm_access_status(self) -> dict[str, Any]:
        platform = self._platform_llm_profile()
        membership = self.membership()
        limit = max(1, int(self._system_credentials.get("member_daily_limit") or 50))
        used = self._platform_usage_today() if membership["active"] else 0
        resolved = self._resolved_llm_profile()
        return {
            "mode": "platform" if membership["active"] else "locked",
            "resolved_source": resolved.get("source") or "none",
            "resolved_model": str(resolved.get("primary", {}).get("model") or ""),
            "platform_configured": self._profile_configured(platform["primary"]),
            "membership": membership,
            "daily_limit": limit,
            "used_today": used,
            "remaining_calls": None if membership["is_admin"] else max(0, limit - used),
        }

    def _platform_usage_today(self) -> int:
        now = datetime.now().astimezone()
        start = now.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
        return self.database.count_llm_usage_since(
            self.current_user_id,
            "platform",
            start.isoformat(timespec="seconds"),
        )

    def system_status(self) -> dict[str, Any]:
        platform = self._platform_llm_profile()
        model_pool = []
        for item in self._system_credentials.get("llm_models") or []:
            if not isinstance(item, dict):
                continue
            profile = {
                "api_key": str(item.get("api_key") or ""),
                "base_url": str(item.get("base_url") or ""),
                "model": str(item.get("model") or ""),
            }
            model_pool.append(
                {
                    "id": str(item.get("id") or ""),
                    "name": str(item.get("name") or ""),
                    "base_url": profile["base_url"],
                    "model": profile["model"],
                    "configured": self._profile_configured(profile),
                }
            )
        return {
            "data": {
                "configured": self.configured,
                "ifind": self.ifind.status(),
                "background_refresh_enabled": bool(
                    self._system_credentials.get("background_refresh_enabled", True)
                ),
                **self.database.status(),
                "jobs": self.jobs.repository.recent(12),
            },
            "llm": {
                "primary_configured": self._profile_configured(platform["primary"]),
                "fallback_configured": self._profile_configured(platform["fallback"]),
                "models": model_pool,
                "primary_model_id": str(self._system_credentials.get("primary_model_id") or ""),
                "fallback_model_id": str(self._system_credentials.get("fallback_model_id") or ""),
            },
            "membership": {
                "member_daily_limit": max(
                    1, int(self._system_credentials.get("member_daily_limit") or 50)
                )
            },
        }

    def save_system_settings(self, payload: dict[str, Any]) -> dict[str, Any]:
        current = dict(self._system_credentials)
        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 []
            if isinstance(item, dict) and item.get("id")
        }
        raw_models = payload.get("models")
        models: list[dict[str, str]] = []
        if raw_models is not None:
            if not isinstance(raw_models, list) or len(raw_models) > 20:
                raise ValueError("模型池格式不正确，最多可保存 20 个模型。")
            seen_ids: set[str] = set()
            seen_names: set[str] = set()
            for index, raw in enumerate(raw_models, start=1):
                if not isinstance(raw, dict):
                    raise ValueError("模型池条目格式不正确。")
                model_id = str(raw.get("id") or f"model-{secrets.token_hex(6)}").strip()
                if not re.fullmatch(r"[A-Za-z0-9_-]{3,80}", model_id) or model_id in seen_ids:
                    raise ValueError("模型 ID 不正确或重复。")
                name = validate_text(raw.get("name"), f"模型 {index} 名称", 50, required=True)
                normalized_name = name.casefold()
                if normalized_name in seen_names:
                    raise ValueError("模型名称不能重复。")
                profile = self._validate_llm_profile(
                    raw,
                    existing_models.get(model_id) or {},
                    required=True,
                    label=name,
                )
                models.append({"id": model_id, "name": name, **profile})
                seen_ids.add(model_id)
                seen_names.add(normalized_name)
        else:
            models = [dict(item) for item in existing_models.values()]
        model_ids = {item["id"] for item in models}
        primary_model_id = str(
            payload.get("primary_model_id", current.get("primary_model_id") or "") or ""
        ).strip()
        fallback_model_id = str(
            payload.get("fallback_model_id", current.get("fallback_model_id") or "") or ""
        ).strip()
        if models and primary_model_id not in model_ids:
            raise ValueError("请从模型池选择主模型。")
        if not models:
            primary_model_id = ""
            fallback_model_id = ""
        if fallback_model_id and fallback_model_id not in model_ids:
            raise ValueError("辅助模型不在模型池中。")
        if fallback_model_id and fallback_model_id == primary_model_id:
            raise ValueError("主模型与辅助模型不能相同。")
        try:
            daily_limit = max(
                1,
                min(
                    1000,
                    int(payload.get("member_daily_limit", current.get("member_daily_limit") or 50)),
                ),
            )
        except (TypeError, ValueError) as exc:
            raise ValueError("会员每日额度应为 1 至 1000。") from exc
        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,
                "member_daily_limit": daily_limit,
                "background_refresh_enabled": bool(
                    payload.get(
                        "background_refresh_enabled",
                        current.get("background_refresh_enabled", True),
                    )
                ),
            }
        )
        self._save_system_credentials(current)
        return self.system_status()

    def test_system_llm_profile(self, model_id: str, payload: dict[str, Any]) -> dict[str, Any]:
        current = next(
            (
                item
                for item in self._system_credentials.get("llm_models") or []
                if str(item.get("id") or "") == model_id
            ),
            {},
        )
        label = validate_text(payload.get("name") or current.get("name"), "模型名称", 50, required=True)
        profile = self._validate_llm_profile(
            payload, current, required=True, label=label
        )
        try:
            return self.llm_gateway.probe(
                profile,
                lambda model: test_llm_connection(
                    model.api_key, model.base_url, model.model
                ),
            )
        except LLMCompilerError as exc:
            raise ValueError(str(exc)) from exc

    def admin_users(self) -> list[dict[str, Any]]:
        original_user_id = getattr(self._request_context, "user_id", 0)
        original_credentials = getattr(self._request_context, "credentials", {})
        original_access = getattr(self._request_context, "access", {})
        rows = []
        try:
            for user in self.database.list_users():
                self._request_context.user_id = int(user["id"])
                self._request_context.access = user
                membership = self.membership()
                used = self._platform_usage_today() if membership["active"] else 0
                rows.append({
                    **user,
                    "membership_active": membership["active"],
                    "membership_subscribed": membership["subscribed"],
                    "used_today": used,
                })
        finally:
            self._request_context.user_id = original_user_id
            self._request_context.credentials = original_credentials
            self._request_context.access = original_access
        return rows

    def update_membership(self, payload: dict[str, Any]) -> None:
        try:
            user_id = int(payload.get("user_id"))
        except (TypeError, ValueError) as exc:
            raise ValueError("会员账号不正确。") from exc
        status = str(payload.get("status") or "inactive")
        if status not in {"active", "inactive", "suspended"}:
            raise ValueError("会员状态不正确。")
        access = self.database.user_access(user_id)
        if not access:
            raise ValueError("用户不存在。")
        starts_at = None
        expires_at = None
        plan = ""
        if status == "active":
            duration = str(payload.get("duration") or "").strip()
            durations = {
                "1_month": (1, "1个月"),
                "3_months": (3, "3个月"),
                "12_months": (12, "12个月"),
                "3_years": (36, "3年"),
                "permanent": (0, "永久"),
            }
            if duration not in durations:
                raise ValueError("请选择会员开通时长。")
            now = datetime.now(timezone.utc)
            existing_start = _parse_iso_datetime(access.get("membership_starts_at"))
            existing_expiry = _parse_iso_datetime(access.get("membership_expires_at"))
            starts = existing_start if existing_start and existing_start <= now else now
            months, plan = durations[duration]
            starts_at = starts.isoformat(timespec="seconds")
            if months:
                renewal_base = existing_expiry if existing_expiry and existing_expiry > now else now
                expires_at = _add_months(renewal_base, months).isoformat(timespec="seconds")
        if not self.database.update_membership(
            user_id, status, plan, starts_at, expires_at
        ):
            raise ValueError("用户不存在。")

    def request_background_sync(self, trade_date: str) -> bool:
        normalized = normalize_date(trade_date)
        key = f"manual:{normalized}:{time.time_ns()}"
        return self.jobs.submit(
            "market.refresh",
            key,
            lambda: self.sync_dashboard(normalized),
            {"trade_date": normalized, "trigger": "administrator"},
        )

    def _background_refresh_tick(self) -> None:
        if not (
            self.configured
            and self._system_credentials.get("background_refresh_enabled", True)
        ):
            return
        today = date.today().strftime("%Y%m%d")
        snapshot = self.database.get_snapshot(today) or {}
        if self._realtime_snapshot_due(today, snapshot):
            bucket = int(time.time() // 5)
            self.jobs.submit(
                "market.refresh",
                f"realtime:{today}:{bucket}",
                lambda: self.sync_dashboard(today),
                {"trade_date": today, "trigger": "realtime-poll"},
            )
        self._schedule_automatic_screeners(today, snapshot)

    def register_account(self, username: str, password: str) -> dict[str, Any]:
        username = username.strip()
        self._validate_account_input(username, password)
        with self.auth_lock:
            salt, password_digest = hash_password(password)
            user = self.database.create_user(username, salt, password_digest)
        return self.create_account_session(user)

    def login_account(self, username: str, password: str) -> dict[str, Any]:
        username = username.strip()
        if not username or not password:
            raise ValueError("账号名和密码不能为空。")
        user = self.database.user_by_username(username)
        if not user or not verify_password(
            password,
            str(user.get("password_salt") or ""),
            str(user.get("password_hash") or ""),
        ):
            raise ValueError("账号名或密码不正确。")
        return self.create_account_session(user)

    def change_password(self, current_password: str, new_password: str) -> None:
        current_password = str(current_password or "")
        self._validate_account_input(str(self.database.user_access(self.current_user_id)["username"]), new_password)
        credentials = self.database.user_password(self.current_user_id)
        if not credentials or not verify_password(
            current_password,
            str(credentials.get("password_salt") or ""),
            str(credentials.get("password_hash") or ""),
        ):
            raise ValueError("当前密码不正确。")
        salt, digest = hash_password(new_password)
        if not self.database.update_user_password(self.current_user_id, salt, digest):
            raise ValueError("账号不存在。")

    def create_account_session(self, user: dict[str, Any]) -> dict[str, Any]:
        session_token = secrets.token_urlsafe(32)
        csrf_token = secrets.token_urlsafe(24)
        expires = datetime.now(timezone.utc) + timedelta(seconds=SESSION_MAX_AGE)
        self.database.create_session(
            token_hash(session_token),
            int(user["id"]),
            csrf_token,
            expires.isoformat(timespec="seconds"),
        )
        self.bind_user(int(user["id"]))
        access = self.database.user_access(int(user["id"])) or {}
        return {
            "user": {
                "id": int(user["id"]),
                "username": str(user["username"]),
                "role": str(access.get("role") or "user"),
                "membership": self.membership(),
            },
            "session_token": session_token,
            "csrf_token": csrf_token,
        }

    @staticmethod
    def _validate_account_input(username: str, password: str) -> None:
        if not USERNAME_PATTERN.fullmatch(username):
            raise ValueError("账号名应为 3 至 30 位中文、字母、数字、下划线或连字符。")
        if len(password) < 8 or len(password) > 128:
            raise ValueError("密码长度应为 8 至 128 位。")
        if password.isalpha() or password.isdigit():
            raise ValueError("密码应同时包含字母、数字或符号中的至少两类。")

    def save_birth_profile(self, payload: dict[str, Any]) -> dict[str, Any]:
        birth_datetime = str(payload.get("birth_datetime") or "").strip()
        gender = str(payload.get("gender") or "unspecified").strip()
        current_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
        personal = build_personal_field(birth_datetime, gender, current_date)
        encrypted = self.vault.encrypt_json(
            {"birth_datetime": birth_datetime, "gender": gender}
        )
        self.database.save_user_birth_profile(self.current_user_id, encrypted)
        return self._public_personal_profile(personal)

    def stored_birth_profile(self) -> dict[str, str] | None:
        encrypted = self.database.get_user_birth_profile(self.current_user_id)
        if not encrypted:
            return None
        payload = self.vault.decrypt_json(encrypted)
        birth_datetime = str(payload.get("birth_datetime") or "").strip()
        if not birth_datetime:
            return None
        return {
            "birth_datetime": birth_datetime,
            "gender": str(payload.get("gender") or "unspecified"),
        }

    def account_personal_field(
        self,
        current_date: str,
        current_field: dict[str, Any],
        public: bool = False,
    ) -> dict[str, Any] | None:
        stored = self.stored_birth_profile()
        if not stored:
            return None
        personal = build_personal_field(
            stored["birth_datetime"],
            stored["gender"],
            current_date,
            current_field,
        )
        if public:
            return self._public_personal_profile(personal)
        personal.pop("birth", None)
        return personal

    @staticmethod
    def _public_personal_profile(personal: dict[str, Any]) -> dict[str, Any]:
        allowed = {
            "day_master",
            "ten_god_tendency",
            "element_balance",
            "balance_tendency",
            "current",
            "notice",
        }
        return {key: value for key, value in personal.items() if key in allowed}

    def get_dashboard(self, trade_date: str, force: bool = False) -> dict[str, Any]:
        normalized_date = normalize_date(trade_date)
        now = datetime.now().astimezone()
        if (
            normalized_date == now.strftime("%Y%m%d")
            and now.time().replace(tzinfo=None) < datetime.strptime("09:15", "%H:%M").time()
        ):
            previous = self.database.get_latest_real_snapshot(normalized_date, strictly_before=True)
            if previous:
                carried = self._carry_dashboard(previous, normalized_date, "盘前沿用最近交易日收盘行情")
                return self._apply_reason_overrides(self._with_storage(carried, cached=True))
        if not force:
            snapshot = self.database.get_snapshot(normalized_date)
            if snapshot and str((snapshot.get("meta") or {}).get("source") or "") != "demo":
                snapshot = copy.deepcopy(snapshot)
                if normalized_date != now.strftime("%Y%m%d"):
                    snapshot.setdefault("meta", {}).update(
                        {"realtime": False, "market_status": "closed"}
                    )
                if not self._dashboard_sentiment_ready(snapshot):
                    snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date)
                    self.database.save_snapshot(
                        normalized_date,
                        str((snapshot.get("meta") or {}).get("source") or "tushare"),
                        snapshot,
                    )
                snapshot.setdefault("meta", {})["requested_date"] = self._display_compact_date(normalized_date)
                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 int(overview.get("sentiment_engine_version") or 0) == SENTIMENT_ENGINE_VERSION and 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]}"

    def _carry_dashboard(
        self, snapshot: dict[str, Any], requested_date: str, reason: str
    ) -> dict[str, Any]:
        carried = copy.deepcopy(snapshot)
        meta = carried.setdefault("meta", {})
        meta.update(
            {
                "requested_date": self._display_compact_date(requested_date),
                "carried_forward": True,
                "realtime": False,
                "market_status": "closed",
                "notice": reason,
            }
        )
        return carried

    def _realtime_snapshot_due(
        self,
        normalized_date: str,
        snapshot: dict[str, Any],
    ) -> bool:
        if not self.configured or normalized_date != date.today().strftime("%Y%m%d"):
            return False
        now = datetime.now().astimezone()
        local_time = now.time().replace(tzinfo=None)
        realtime_start = datetime.strptime("09:15", "%H:%M").time()
        morning_end = datetime.strptime("11:35", "%H:%M").time()
        afternoon_start = datetime.strptime("12:55", "%H:%M").time()
        realtime_end = datetime.strptime("15:05", "%H:%M").time()
        in_session = (
            realtime_start <= local_time < morning_end
            or afternoon_start <= local_time < realtime_end
        )
        if not in_session:
            return False
        meta = snapshot.get("meta") or {}
        snapshot_trade_date = str(meta.get("trade_date") or "").replace("-", "")
        if snapshot_trade_date and snapshot_trade_date != normalized_date:
            return False
        if not meta.get("realtime"):
            return True
        try:
            updated_at = datetime.fromisoformat(str(meta.get("updated_at") or ""))
            if updated_at.tzinfo is None:
                updated_at = updated_at.replace(tzinfo=now.tzinfo)
        except ValueError:
            return True
        age_seconds = (now - updated_at.astimezone(now.tzinfo)).total_seconds()
        return age_seconds >= 8

    def sync_dashboard(self, trade_date: str) -> dict[str, Any]:
        normalized_date = normalize_date(trade_date)
        source = "tushare"
        with self.sync_lock:
            sync_id = self.database.start_sync(normalized_date, source)
            try:
                if not self.configured:
                    raise TushareError("公共行情尚未配置")
                dashboard = self._tushare_client().dashboard(normalized_date)

                dashboard["meta"]["source"] = source
                dashboard["meta"]["requested_date"] = self._display_compact_date(normalized_date)
                dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date)
                record_count = self._record_count(dashboard)
                actual_date = normalize_date(
                    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",
                    record_count,
                    dashboard.get("meta", {}).get("notice", ""),
                    source,
                )
                return self._apply_reason_overrides(self._with_storage(dashboard, cached=False))
            except TushareError as exc:
                fallback = self.database.get_latest_real_snapshot(normalized_date)
                if fallback:
                    carried = self._carry_dashboard(
                        fallback, normalized_date, f"最新行情暂不可用，沿用最近收盘快照：{exc}"
                    )
                    self.database.finish_sync(
                        sync_id, "fallback", self._record_count(carried), str(exc), "tushare"
                    )
                    return self._apply_reason_overrides(self._with_storage(carried, cached=True))
                self.database.finish_sync(sync_id, "failed", message=str(exc))
                raise ValueError("暂无可用的真实行情快照，请等待后台完成首次同步。") from exc
            except Exception as exc:
                self.database.finish_sync(sync_id, "failed", message=str(exc))
                raise

    def _enrich_dashboard_sentiment(
        self,
        dashboard: dict[str, Any],
        end_date: str,
    ) -> dict[str, Any]:
        history = self.database.list_snapshot_payloads(end_date, 260)
        return apply_sentiment_to_dashboard(dashboard, history)

    def sentiment_history(self, trade_date: str, limit: int = 20) -> dict[str, Any]:
        normalized_date = normalize_date(trade_date)
        limit = max(10, min(120, int(limit)))
        full_series = build_sentiment_history(
            self.database.list_snapshot_payloads(normalized_date, 240)
        )
        series = latest_contiguous_history(full_series)
        rows = series[-limit:]
        return {
            "trade_date": rows[-1]["trade_date"] if rows else normalized_date,
            "available_days": len(series),
            "stored_days": len(full_series),
            "requested_days": limit,
            "rows": rows,
            "weights": COMPONENT_WEIGHTS,
            "normalization": rows[-1]["normalization"] if rows else "固定锚点",
        }

    def rotation_history(self, trade_date: str, limit: int = 9) -> dict[str, Any]:
        normalized_date = normalize_date(trade_date)
        # 板块轮动固定展示最近 9 个交易日，按由近到远排列。
        limit = 9
        snapshots = self.database.list_snapshot_payloads(normalized_date, 240)
        by_trade_date: dict[str, dict[str, Any]] = {}
        for snapshot in snapshots:
            meta = snapshot.get("meta") or {}
            actual_date = str(meta.get("trade_date") or snapshot.get("_snapshot_date") or "")
            compact_date = actual_date.replace("-", "")
            if len(compact_date) == 8:
                by_trade_date[compact_date] = snapshot

        sentiment_dates = {
            str(row.get("trade_date") or "").replace("-", "")
            for row in latest_contiguous_history(build_sentiment_history(snapshots))
        }
        ordered_dates = sorted(
            date_key for date_key in by_trade_date
            if not sentiment_dates or date_key in sentiment_dates
        )[-limit:][::-1]
        rows = []
        for date_key in ordered_dates:
            snapshot = by_trade_date[date_key]
            sector_context = {
                str(item.get("name") or ""): item
                for item in snapshot.get("sectors") or []
            }
            sectors = []
            for item in (snapshot.get("sector_rotation") or [])[:12]:
                name = str(item.get("name") or "").strip()
                context = sector_context.get(name, {})
                sectors.append(
                    {
                        "name": name,
                        "rank": int(item.get("rank") or len(sectors) + 1),
                        "trend": item.get("trend") or "持平",
                        "count": int(item.get("count") or 0),
                        "strength": float(item.get("strength") or context.get("strength") or 0),
                        "change": float(context.get("change") or 0),
                        "leader": item.get("leader") or context.get("leader") or "--",
                    }
                )
            rows.append(
                {
                    "trade_date": f"{date_key[:4]}-{date_key[4:6]}-{date_key[6:]}",
                    "sectors": sectors,
                }
            )
        return {
            "trade_date": rows[0]["trade_date"] if rows else normalized_date,
            "available_days": len(ordered_dates),
            "requested_days": limit,
            "rows": rows,
        }

    def rotation_sector_members(self, trade_date: str, sector_name: str) -> dict[str, Any]:
        normalized_date = normalize_date(trade_date)
        sector_name = validate_text(sector_name, "板块名称", 60, required=True)
        dashboard = self.get_dashboard(normalized_date)
        actual_date = normalize_date(
            str((dashboard.get("meta") or {}).get("trade_date") or normalized_date)
        )
        cache_key = f"{actual_date}:{sector_name}"
        cached = self.database.get_data_snapshot("rotation_sector_members_v1", cache_key)
        if cached:
            cached["meta"] = {**(cached.get("meta") or {}), "cached": True}
            return cached
        if not self.configured:
            raise ValueError("板块成分数据暂不可用。")

        representative = next(
            (
                item for item in dashboard.get("limits") or []
                if str(item.get("sector") or "").strip() == sector_name
            ),
            None,
        )
        if not representative:
            raise ValueError("未找到该板块的代表股票，暂时无法核验成分股。")
        raw_code = str(representative.get("ts_code") or representative.get("code") or "")
        if "." in raw_code:
            ts_code = raw_code
        elif raw_code.startswith(("4", "8", "92")):
            ts_code = f"{raw_code}.BJ"
        elif raw_code.startswith(("6", "68", "90")):
            ts_code = f"{raw_code}.SH"
        else:
            ts_code = f"{raw_code}.SZ"
        client = self._tushare_client()
        try:
            industry = client.sw_stock_industry(ts_code, actual_date)
            sector_code = str(industry.get("l2_code") or "")
            members = client.sw_sector_members(sector_code, actual_date)
        except TushareError as exc:
            raise ValueError(f"该板块成分股暂不可用：{exc}") from exc

        daily_rows = self.database.daily_bars_for_date(actual_date)
        if len(daily_rows) < 1000:
            try:
                daily_rows = client.query(
                    "daily",
                    {"trade_date": actual_date},
                    "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
                )
                if daily_rows:
                    self.database.upsert_daily_bars(daily_rows)
            except TushareError:
                daily_rows = self.database.daily_bars_for_date(actual_date)
        daily_map = {str(item.get("ts_code") or ""): item for item in daily_rows}
        rows = []
        for member in members:
            member_code = str(member.get("ts_code") or "")
            quote = daily_map.get(member_code) or {}
            rows.append(
                {
                    "code": member_code.split(".")[0],
                    "ts_code": member_code,
                    "name": str(member.get("name") or "--"),
                    "change": quote.get("pct_chg"),
                    "open": quote.get("open"),
                    "close": quote.get("close"),
                    "amount_billion": (
                        round(float(quote.get("amount") or 0) / 100000, 2)
                        if quote else None
                    ),
                    "quoted": bool(quote),
                }
            )
        rows.sort(
            key=lambda item: (
                bool(item.get("quoted")),
                float(item.get("change") or -999),
                float(item.get("amount_billion") or 0),
            ),
            reverse=True,
        )
        result = {
            "meta": {
                "trade_date": self._display_compact_date(actual_date),
                "sector_name": str(industry.get("l2_name") or sector_name),
                "sector_code": sector_code,
                "member_count": len(rows),
                "quoted_count": sum(bool(item.get("quoted")) for item in rows),
                "cached": False,
            },
            "rows": rows,
        }
        self.database.save_data_snapshot(
            "rotation_sector_members_v1", cache_key, "tushare", result
        )
        return result

    def status(self) -> dict[str, Any]:
        llm_access = self.llm_access_status()
        return {
            "configured": self.configured,
            "mode": "tushare" if self.configured else "unavailable",
            "llm_configured": self.llm_configured,
            "llm_model": self.llm_primary_model if self.llm_configured else "",
            "llm_fallback_configured": self.llm_fallback_configured,
            "llm_fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
            "llm_access": llm_access,
            "birth_profile_configured": bool(self.stored_birth_profile()),
            "birth_profile": self.stored_birth_profile(),
            **self.database.status(),
        }

    def realtime_aggregate_health(self, sector: str = "") -> dict[str, Any]:
        sector = validate_text(sector, "板块名称", 50)
        return self.realtime_aggregator.health_snapshot(sector)

    def _market_insights(self) -> MarketInsightsService:
        if not self.configured:
            raise ValueError("行情数据尚未配置。")
        return MarketInsightsService(
            self.database,
            self._tushare_client(),
            ifind=self.ifind,
        )

    def auction_center(self, trade_date: str, force: bool = False) -> dict[str, Any]:
        return self._market_insights().auction_center(
            normalize_date(trade_date), force, self.current_user_id
        )

    def theme_library(self, trade_date: str, force: bool = False) -> dict[str, Any]:
        return self._market_insights().theme_library(normalize_date(trade_date), force)

    def theme_detail(self, code: str, trade_date: str) -> dict[str, Any]:
        return self._market_insights().theme_detail(code, normalize_date(trade_date))

    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 ""

    @staticmethod
    def _strategy_missing_data(
        strategy: dict[str, Any], factor_dates: list[str], factor_health: dict[str, Any]
    ) -> list[str]:
        formula = strategy.get("formula") or {}
        meta = formula.get("meta") or {}
        used_fields = {
            str(item.get("field") or "")
            for item in list(formula.get("filters") or []) + list(formula.get("score") or [])
        }
        valuation_fields = {"pe_ttm", "pb", "ps_ttm", "dividend_yield_ttm", "total_mv_billion"}
        fundamental_fields = {"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "ocf_to_opincome"}
        auction_fields = {"auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio"}
        missing = []
        required_history = max(21, min(260, int(meta.get("history_days") or 21)))
        if len(factor_dates) < required_history:
            missing.append(f"历史行情（需{required_history}日）")
        if used_fields & valuation_fields and not factor_health["valuation"]:
            missing.append("估值数据")
        if used_fields & fundamental_fields and not factor_health["fundamental"]:
            missing.append("财务质量")
        if meta.get("requires_valuation") and not factor_health["valuation"]:
            missing.append("估值数据")
        if meta.get("requires_fundamental") and not factor_health["fundamental"]:
            missing.append("财务质量")
        if "dividend_years" in used_fields and not factor_health["dividend_history"]:
            missing.append("历年分红")
        if used_fields & auction_fields and not factor_health["auction"]:
            missing.append("竞价数据")
        if meta.get("requires_benchmark") and not factor_health.get("benchmark"):
            missing.append("沪深300基准")
        if meta.get("requires_moneyflow_history") and not factor_health.get("moneyflow_history"):
            missing.append("近5日资金流")
        if meta.get("requires_earnings_events") and not factor_health.get("earnings_events"):
            missing.append("业绩预告与快报")
        if meta.get("requires_popularity") and not factor_health.get("popularity"):
            missing.append("当日人气榜")
        if meta.get("requires_institutions") and not factor_health.get("institutions"):
            missing.append("龙虎榜机构席位")
        return list(dict.fromkeys(missing))

    def screener_setup(self, trade_date: str) -> dict[str, Any]:
        normalized_date = normalize_date(trade_date)
        regime = self.screener.detect_regime(normalized_date)
        factor_dates = self.database.factor_dates(normalized_date, 300)
        auction_dates = self.database.auction_factor_dates(normalized_date, 100)
        factor_health = self.screener.factor_health(normalized_date)
        strategies = self.database.list_screener_strategies(self.current_user_id)
        for strategy in strategies:
            missing = self._strategy_missing_data(strategy, factor_dates, factor_health)
            strategy["data_ready"] = not missing
            strategy["missing_data"] = missing
        automatic_results = self.database.screener_runs_for_date(0, normalized_date)
        personal_results = self.database.screener_runs_for_date(
            self.current_user_id, normalized_date
        )
        recent_results = [
            *[item for item in automatic_results if item.get("meta", {}).get("mode") in {"smart", "curated"}],
            *[item for item in personal_results if item.get("meta", {}).get("mode") == "quant"],
        ]
        latest_results: dict[str, dict[str, Any]] = {}
        for result in reversed(recent_results):
            mode = str(result.get("meta", {}).get("mode") or "smart")
            latest_results[mode] = result
        automatic_status = self.database.get_data_snapshot(
            "screener_auto_v1", normalized_date
        ) or {}
        return {
            "trade_date": normalized_date,
            "regime": regime,
            "regimes": [{"id": key, "label": value} for key, value in REGIMES.items()],
            "strategies": strategies,
            "factor_fields": [{"id": key, "label": value} for key, value in FACTOR_FIELDS.items()],
            "factor_groups": [
                {
                    "name": name,
                    "fields": [{"id": field, "label": FACTOR_FIELDS[field]} for field in fields],
                }
                for name, fields in FACTOR_GROUPS.items()
            ],
            "operators": [">", ">=", "<", "<=", "==", "between"],
            "factor_data": {
                "date_count": len(factor_dates),
                "start_date": factor_dates[0] if factor_dates else "",
                "end_date": factor_dates[-1] if factor_dates else "",
                "ready": len(factor_dates) >= 21,
                "auction_date_count": len(auction_dates),
                "auction_ready": bool(auction_dates and auction_dates[-1] == factor_dates[-1]) if factor_dates else False,
                "health": factor_health,
            },
            "llm": {
                "configured": self.llm_configured,
                "model": self.llm_primary_model if self.llm_configured else "",
                "fallback_configured": self.llm_fallback_configured,
                "fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
            },
            "latest_results": latest_results,
            "recent_results": recent_results,
            "automatic_status": automatic_status,
            # Kept during the client transition for compatibility with older frontends.
            "latest_result": latest_results.get("smart"),
        }

    def screener_tracking(self, limit: int = 12) -> dict[str, Any]:
        return self.strategy_tracking.list_tracking(self.current_user_id, limit)

    def add_screener_tracking(self, payload: dict[str, Any]) -> dict[str, Any]:
        try:
            run_id = int(payload.get("run_id") or 0)
        except (TypeError, ValueError) as exc:
            raise ValueError("选股批次无效。") from exc
        code = str(payload.get("code") or "").strip()
        if run_id <= 0 or not re.fullmatch(r"\d{6}", code):
            raise ValueError("选股批次或股票代码无效。")
        return self.strategy_tracking.add_candidate(self.current_user_id, run_id, code)

    def remove_screener_tracking(self, track_id: int) -> dict[str, Any]:
        return self.strategy_tracking.remove_candidate(self.current_user_id, track_id)

    def refresh_screener_tracking(self, trade_date: str) -> dict[str, Any]:
        normalized_date = normalize_date(trade_date)
        notice = ""
        if self.configured:
            try:
                FactorDataService(self.database, self._tushare_client()).sync(
                    normalized_date, 15
                )
            except TushareError:
                notice = "最新日线暂未补齐，已按现有数据更新跟踪。"
        else:
            notice = "公共行情尚未配置，已按现有数据更新跟踪。"
        return {
            "tracking": self.screener_tracking(),
            "notice": notice,
        }

    def alert_center(self, status: str = "all", as_of: str = "") -> dict[str, Any]:
        tracking = self.strategy_tracking.list_tracking(self.current_user_id, 12)
        self.alert_service.sync_strategy_tracking(self.current_user_id, tracking)
        return self.alert_service.list_alerts(
            self.current_user_id, status, as_of
        )

    def create_alert(self, payload: dict[str, Any]) -> dict[str, Any]:
        alert_id = self.alert_service.create_manual(self.current_user_id, payload)
        return {"id": alert_id, **self.alert_center()}

    def mark_alert_read(self, alert_id: int) -> dict[str, Any]:
        self.alert_service.mark_read(self.current_user_id, alert_id)
        return self.alert_center()

    def mark_all_alerts_read(self, as_of: str = "") -> dict[str, Any]:
        compact_date = self.alert_service.calendar_date(as_of or date.today().isoformat())
        self.alert_service.mark_all_read(self.current_user_id, compact_date)
        return self.alert_center(as_of=compact_date)

    def delete_alert(self, alert_id: int) -> dict[str, Any]:
        deleted = self.alert_service.delete(self.current_user_id, alert_id)
        return {"deleted": deleted, **self.alert_center()}

    def trade_entries(
        self, start_date: str = "", end_date: str = "", code: str = ""
    ) -> dict[str, Any]:
        return self.trade_journal.list_entries(
            self.current_user_id, start_date, end_date, code
        )

    def review_watchlist(self, trade_date: str) -> dict[str, Any]:
        normalized_date = normalize_date(trade_date)
        items = self.database.list_watchlist(self.current_user_id)
        if not items:
            return {"items": [], "trade_date": normalized_date}

        resolved_date = normalized_date
        if self.configured:
            try:
                client = self._tushare_client()
                resolved_date, _ = client.resolve_trade_context(normalized_date)
                history = self.database.watchlist_price_history(
                    [str(item["code"]) for item in items], resolved_date
                )
                missing_codes = [
                    str(item["code"]) for item in items
                    if len(history.get(str(item["code"])) or []) < 6
                ]
                start_date = (
                    datetime.strptime(resolved_date, "%Y%m%d") - timedelta(days=24)
                ).strftime("%Y%m%d")
                for code in missing_codes:
                    rows = client.query(
                        "daily",
                        {
                            "ts_code": tushare_code(code),
                            "start_date": start_date,
                            "end_date": resolved_date,
                        },
                        "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
                    )
                    if rows:
                        self.database.upsert_daily_bars(rows)
                if missing_codes:
                    history = self.database.watchlist_price_history(
                        [str(item["code"]) for item in items], resolved_date
                    )
            except (TushareError, ValueError):
                history = self.database.watchlist_price_history(
                    [str(item["code"]) for item in items], resolved_date
                )
        else:
            history = self.database.watchlist_price_history(
                [str(item["code"]) for item in items], resolved_date
            )

        auction_scores: dict[str, Any] = {}
        try:
            auction = self.auction_center(normalized_date, False)
            auction_scores = {
                str(row.get("code") or ""): row.get("attention_score")
                for row in (auction.get("watchlist_rows") or [])
                if row.get("available", True)
            }
        except (TushareError, ValueError):
            pass

        enriched = []
        for item in items:
            code = str(item.get("code") or "")
            bars = history.get(code) or []
            latest = bars[-1] if bars else {}
            close = float(latest.get("close") or 0)
            base_close = float(bars[-6].get("close") or 0) if len(bars) >= 6 else 0
            enriched.append(
                {
                    **item,
                    "change": (
                        round(float(latest.get("pct_chg") or 0), 2) if latest else None
                    ),
                    "return_5d": (
                        round((close / base_close - 1) * 100, 2)
                        if close > 0 and base_close > 0 else None
                    ),
                    "attention_score": auction_scores.get(code),
                    "market_date": str(latest.get("trade_date") or ""),
                }
            )
        return {"items": enriched, "trade_date": resolved_date}

    def save_trade_entry(self, payload: dict[str, Any]) -> dict[str, Any]:
        trade_id = self.trade_journal.save(self.current_user_id, payload)
        return {"id": trade_id, **self.trade_entries()}

    def delete_trade_entry(self, trade_id: int) -> dict[str, Any]:
        deleted = self.trade_journal.delete(self.current_user_id, trade_id)
        return {"deleted": deleted, **self.trade_entries()}

    def assistant_messages(self) -> list[dict[str, Any]]:
        return self.database.list_assistant_messages(self.current_user_id)

    def clear_assistant_messages(self) -> int:
        return self.database.delete_assistant_messages(self.current_user_id)

    def assistant_stream(self, payload: dict[str, Any]):
        question = validate_text(payload.get("question"), "问题", 2000, required=True)
        trade_date = normalize_date(
            str(payload.get("trade_date") or date.today().isoformat())
        )
        context = self._assistant_context(trade_date)
        history = [
            {"role": item["role"], "content": str(item["content"])[:4000]}
            for item in self.assistant_messages()[-12:]
            if item.get("role") in {"user", "assistant"}
        ]
        def generate():
            answer_parts: list[str] = []
            events = self.llm_gateway.stream(
                "assistant",
                "review-assistant-v1",
                lambda profile: stream_review_assistant(
                    context,
                    question,
                    history,
                    profile.api_key,
                    profile.base_url,
                    profile.model,
                ),
                (ReviewAssistantError,),
            )
            for event in events:
                if event.kind == "delta":
                    chunk = str(event.value or "")
                    answer_parts.append(chunk)
                    yield chunk
                elif event.kind == "complete":
                    self.database.save_assistant_exchange(
                        self.current_user_id,
                        question,
                        "".join(answer_parts).strip(),
                        trade_date,
                    )

        return generate()

    def _assistant_context(self, trade_date: str) -> dict[str, Any]:
        dashboard = self.get_dashboard(trade_date)
        actual_date = normalize_date(
            str((dashboard.get("meta") or {}).get("trade_date") or trade_date)
        )
        sentiment = self.sentiment_history(actual_date, 10)
        tracking = self.strategy_tracking.list_tracking(self.current_user_id, 5)
        alerts = self.alert_service.list_alerts(
            self.current_user_id, "all", date.today().isoformat()
        )
        trades = self.trade_journal.list_entries(
            self.current_user_id, end_date=actual_date
        )
        return {
            "data_date": actual_date,
            "market": {
                "overview": dashboard.get("overview") or {},
                "top_sectors": (dashboard.get("sectors") or [])[:8],
                "limit_performance": dashboard.get("limit_performance") or {},
                "sentiment_history": (sentiment.get("rows") or [])[-10:],
            },
            "personal": {
                "watchlist": self.database.list_watchlist(self.current_user_id)[:30],
                "review_notes": self.database.list_notes(
                    self.current_user_id, scope="daily"
                )[:10],
                "strategy_tracking": {
                    "summary": tracking.get("summary") or {},
                    "batches": (tracking.get("batches") or [])[:5],
                },
                "alerts": (alerts.get("items") or [])[:20],
                "trade_summary": trades.get("summary") or {},
                "trade_entries": (trades.get("items") or [])[:30],
            },
        }

    def sync_screener_data(self, trade_date: str, lookback: int = 45) -> dict[str, Any]:
        if not self.configured:
            raise ValueError("请先配置 Tushare Token。")
        normalized_date = normalize_date(trade_date)
        lookback = max(25, min(260, int(lookback)))
        with self.sync_lock:
            return FactorDataService(self.database, self._tushare_client()).sync(
                normalized_date, lookback
            )

    def _schedule_automatic_screeners(
        self, trade_date: str, snapshot: dict[str, Any] | None = None
    ) -> bool:
        normalized_date = normalize_date(trade_date)
        now = datetime.now().astimezone()
        if (
            normalized_date != now.strftime("%Y%m%d")
            or now.weekday() >= 5
            or now.time().replace(tzinfo=None) < datetime.strptime("15:10", "%H:%M").time()
            or self.auto_screener_lock.locked()
        ):
            return False
        snapshot = snapshot or self.database.get_snapshot(normalized_date) or {}
        actual_date = str((snapshot.get("meta") or {}).get("trade_date") or "").replace("-", "")
        if actual_date != normalized_date:
            return False
        marker = self.database.get_data_snapshot("screener_auto_v1", normalized_date) or {}
        if (
            marker.get("status") == "complete"
            and int(marker.get("library_version") or 0) == SCREENER_LIBRARY_VERSION
        ):
            return False
        last_attempt = self._auto_screener_last_attempt.get(normalized_date)
        if last_attempt and (now - last_attempt).total_seconds() < 600:
            return False
        self._auto_screener_last_attempt[normalized_date] = now
        return self.jobs.submit(
            "screener.automatic",
            f"{normalized_date}:v{SCREENER_LIBRARY_VERSION}",
            lambda: self.run_automatic_screeners(normalized_date),
            {"trade_date": normalized_date, "trigger": "post-close"},
        )

    def run_automatic_screeners(self, trade_date: str) -> dict[str, Any]:
        normalized_date = normalize_date(trade_date)
        with self.auto_screener_lock:
            started_at = datetime.now().astimezone().isoformat(timespec="seconds")
            status: dict[str, Any] = {
                "trade_date": normalized_date,
                "library_version": SCREENER_LIBRARY_VERSION,
                "status": "running",
                "started_at": started_at,
                "completed": [],
                "skipped": [],
                "failed": [],
            }
            self.database.save_data_snapshot(
                "screener_auto_v1", normalized_date, "system", status
            )
            try:
                factor_sync = FactorDataService(
                    self.database, self._tushare_client()
                ).sync(normalized_date, 260)
                factor_dates = self.database.factor_dates(normalized_date, 300)
                if not factor_dates or factor_dates[-1] != normalized_date:
                    raise ValueError("当日收盘行情尚未入库")
                factor_health = self.screener.factor_health(normalized_date)
                regime = self.screener.detect_regime(normalized_date)
                regime_id = str(regime.get("id") or "repair")
                strategies = self.database.list_screener_strategies(None)
                jobs = automatic_screener_jobs(strategies, regime_id)
                existing = {
                    (
                        str(item.get("meta", {}).get("mode") or "smart"),
                        str(item.get("meta", {}).get("strategy_name") or ""),
                    )
                    for item in self.database.screener_runs_for_date(0, normalized_date)
                    if int(item.get("meta", {}).get("library_version") or 0)
                    == SCREENER_LIBRARY_VERSION
                }
                required_history = max(
                    [
                        int((job["strategy"].get("formula", {}).get("meta", {}) or {}).get("history_days") or 80)
                        for job in jobs if job.get("strategy")
                    ] or [80]
                )
                factors, actual_date = self.screener.build_factors(
                    normalized_date, history_days=required_history
                )
                if actual_date != normalized_date:
                    raise ValueError("当日因子尚未完成收盘定格")
                for job in jobs:
                    strategy = job["strategy"]
                    mode = str(job["mode"])
                    name = str(strategy.get("name") or "未命名策略")
                    if (mode, name) in existing:
                        status["completed"].append({"mode": mode, "name": name, "cached": True})
                        continue
                    missing = self._strategy_missing_data(
                        strategy, factor_dates, factor_health
                    )
                    if missing:
                        status["skipped"].append(
                            {"mode": mode, "name": name, "reason": "、".join(missing)}
                        )
                        continue
                    try:
                        formula = copy.deepcopy(strategy.get("formula") or {})
                        formula.setdefault("meta", {})["library_version"] = (
                            SCREENER_LIBRARY_VERSION
                        )
                        result = self.screener.screen(
                            0,
                            normalized_date,
                            formula,
                            regime_id,
                            name,
                            False,
                            None,
                            mode,
                            factors,
                            actual_date,
                        )
                        status["completed"].append(
                            {
                                "mode": mode,
                                "name": name,
                                "candidate_count": len(result.get("candidates") or []),
                            }
                        )
                    except Exception as exc:
                        status["failed"].append(
                            {"mode": mode, "name": name, "reason": str(exc)}
                        )
                status.update(
                    {
                        "status": "complete" if not status["failed"] else "partial",
                        "finished_at": datetime.now().astimezone().isoformat(timespec="seconds"),
                        "factor_sync": factor_sync,
                        "regime": regime,
                    }
                )
            except Exception as exc:
                status.update(
                    {
                        "status": "failed",
                        "finished_at": datetime.now().astimezone().isoformat(timespec="seconds"),
                        "error": str(exc),
                    }
                )
            self.database.save_data_snapshot(
                "screener_auto_v1", normalized_date, "system", status
            )
            return status

    def compile_screener_strategy(self, prompt: str, regime: str) -> dict[str, Any]:
        prompt = prompt.strip()
        if not prompt or len(prompt) > 3000:
            raise ValueError("策略描述应为 1 至 3000 个字符。")
        if regime not in REGIMES:
            raise ValueError("市场阶段不支持。")
        notice = ""
        source = self.llm_source
        if source == "platform":
            try:
                gateway_result = self.llm_gateway.call(
                    "screener",
                    "strategy-compiler-v1",
                    lambda profile: compile_strategy_with_llm(
                        prompt,
                        regime,
                        profile.api_key,
                        profile.base_url,
                        profile.model,
                    ),
                    (LLMCompilerError,),
                )
                compiled = gateway_result.value
                if gateway_result.role == "fallback":
                    compiled["compiler"] = "llm_fallback"
                    notice = "智能策略生成服务已自动切换。"
            except LLMGatewayError as exc:
                if exc.code != "unavailable":
                    raise
                compiled = compile_local_strategy(prompt, regime)
                notice = "智能策略生成暂不可用，已使用本地模板。"
        else:
            compiled = compile_local_strategy(prompt, regime)
            notice = "智能策略生成暂不可用，已使用本地模板。"
        compiled["formula"] = self.screener.validate_formula(compiled["formula"])
        compiled["notice"] = notice
        return compiled

    def save_screener_strategy(self, payload: dict[str, Any]) -> dict[str, Any]:
        name = validate_text(payload.get("name"), "策略名称", 60, required=True)
        description = validate_text(payload.get("description"), "策略说明", 1000)
        regimes = payload.get("regimes") or []
        if not isinstance(regimes, list) or not regimes or any(item not in REGIMES for item in regimes):
            raise ValueError("策略适用阶段不正确。")
        formula = self.screener.validate_formula(payload.get("formula") or {})
        strategy_id = self.database.save_screener_strategy(
            self.current_user_id, name, description, regimes, formula
        )
        return {
            "id": strategy_id,
            "strategies": self.database.list_screener_strategies(self.current_user_id),
        }

    def delete_screener_strategy(self, strategy_id: int) -> dict[str, Any]:
        deleted = self.database.delete_screener_strategy(self.current_user_id, strategy_id)
        return {
            "deleted": deleted,
            "strategies": self.database.list_screener_strategies(self.current_user_id),
        }

    def mentor_setup(self, trade_date: str) -> dict[str, Any]:
        normalized_date = normalize_date(trade_date)
        mentors = [
            skill.public()
            for skill in self.mentor_skills.list_skills(
                include_private=self.membership()["is_admin"]
            )
        ]
        if not mentors:
            raise ValueError("游资skills 目录中没有可用的 SKILL.md。")
        stored_preferences = self.database.list_mentor_preferences(self.current_user_id)
        preferences = {item["mentor_id"]: item for item in stored_preferences}
        for default_order, mentor in enumerate(mentors):
            preference = preferences.get(str(mentor.get("id") or ""), {})
            mentor["pinned"] = bool(preference.get("pinned"))
            mentor["sort_order"] = int(preference.get("sort_order", 10000 + default_order))
        mentors.sort(
            key=lambda item: (
                not bool(item.get("pinned")),
                int(item.get("sort_order") or 0),
            )
        )
        for sort_order, mentor in enumerate(mentors):
            mentor["sort_order"] = sort_order
        snapshot = self.database.get_snapshot(normalized_date)
        actual_date = str((snapshot or {}).get("meta", {}).get("trade_date") or normalized_date)
        return {
            "trade_date": actual_date,
            "mentors": mentors,
            "preferences_configured": bool(stored_preferences),
            "llm": {
                "configured": self.llm_configured,
                "model": self.llm_primary_model if self.llm_configured else "",
                "fallback_configured": self.llm_fallback_configured,
                "fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
            },
        }

    def save_mentor_preferences(self, payload: dict[str, Any]) -> dict[str, Any]:
        available_ids = [
            skill.skill_id
            for skill in self.mentor_skills.list_skills(
                include_private=self.membership()["is_admin"]
            )
        ]
        available = set(available_ids)
        raw_order = payload.get("order")
        raw_pinned = payload.get("pinned")
        if not isinstance(raw_order, list) or not isinstance(raw_pinned, list):
            raise ValueError("问师排序格式不正确。")
        ordered_ids: list[str] = []
        for raw_id in raw_order:
            mentor_id = validate_text(raw_id, "问师角色", 100, required=True)
            if mentor_id not in available:
                raise ValueError("问师排序中包含不可用的思维模型。")
            if mentor_id not in ordered_ids:
                ordered_ids.append(mentor_id)
        ordered_ids.extend(mentor_id for mentor_id in available_ids if mentor_id not in ordered_ids)
        pinned_ids = {
            validate_text(raw_id, "问师角色", 100, required=True)
            for raw_id in raw_pinned
        }
        if not pinned_ids.issubset(available):
            raise ValueError("问师置顶中包含不可用的思维模型。")
        self.database.save_mentor_preferences(
            self.current_user_id, ordered_ids, pinned_ids
        )
        return {"saved": True}

    def mentor_stream(self, payload: dict[str, Any]):
        mentor_id = validate_text(payload.get("mentor_id"), "问师角色", 100, required=True)
        question = validate_text(payload.get("question"), "问题", 2000, required=True)
        trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
        history = self._validate_mentor_history(payload.get("history") or [])
        skill = self.mentor_skills.get_skill(
            mentor_id, include_private=self.membership()["is_admin"]
        )
        context = self._build_mentor_context(trade_date, question, skill)

        def generate():
            answer_parts: list[str] = []
            events = self.llm_gateway.stream(
                "mentor",
                f"mentor-skill-v1:{skill.skill_id}",
                lambda profile: stream_with_mentor(
                    skill,
                    context,
                    question,
                    history,
                    profile.api_key,
                    profile.base_url,
                    profile.model,
                ),
                (MentorAgentError,),
            )
            for event in events:
                if event.kind == "delta":
                    chunk = str(event.value or "")
                    answer_parts.append(chunk)
                    yield {"type": "delta", "content": chunk}
                elif event.kind == "complete":
                    self.database.save_mentor_exchange(
                        self.current_user_id,
                        mentor_id,
                        trade_date,
                        question,
                        "".join(answer_parts).strip(),
                        context["data_trade_date"],
                    )
                    yield {
                        "type": "meta",
                        "data_trade_date": context["data_trade_date"],
                        "notice": "智能解读已自动切换可用服务。"
                        if event.role == "fallback"
                        else "",
                    }

        return generate()

    def mentor_messages(self, mentor_id: str, trade_date: str) -> list[dict[str, Any]]:
        mentor_id = validate_text(mentor_id, "问师角色", 100, required=True)
        trade_date = normalize_date(trade_date)
        self.mentor_skills.get_skill(
            mentor_id, include_private=self.membership()["is_admin"]
        )
        return self.database.list_mentor_messages(
            self.current_user_id, mentor_id, trade_date
        )

    def clear_mentor_messages(self, mentor_id: str, trade_date: str) -> int:
        mentor_id = validate_text(mentor_id, "问师角色", 100, required=True)
        trade_date = normalize_date(trade_date)
        self.mentor_skills.get_skill(
            mentor_id, include_private=self.membership()["is_admin"]
        )
        return self.database.delete_mentor_messages(
            self.current_user_id, mentor_id, trade_date
        )

    @staticmethod
    def _heaven_manual_schema(market_mode: str) -> dict[str, dict[str, Any]]:
        intraday = market_mode == "intraday"
        fields = {
            "stock_amount_percentile": {"line": 1, "label": "成交额全市场分位", "unit": "%", "min": 0, "max": 100},
            "stock_turnover_rate": {"line": 1, "label": "个股换手率", "unit": "%", "min": 0, "max": 100},
            "stock_turnover_relative": {"line": 1, "label": "相对市场换手", "unit": "倍", "min": 0, "max": 20},
            "stock_volume_activity_ratio": {"line": 1, "label": "同进度量能", "unit": "倍", "min": 0, "max": 20},
            "stock_seal_amount_million": {"line": 1, "label": "封单金额", "unit": "万元", "min": 0, "max": 100000000},
            "stock_open_times": {"line": 1, "label": "开板次数", "unit": "次", "min": 0, "max": 100, "integer": True},
            "stock_change": {"line": 2, "label": "个股涨跌幅", "unit": "%", "min": -100, "max": 100},
            "stock_streak": {"line": 2, "label": "连板高度", "unit": "板", "min": 0, "max": 100, "integer": True},
            "stock_status": {"line": 2, "label": "个股状态", "type": "select", "options": ["普通", "涨停", "炸板", "跌停"]},
            "sector_name": {"line": [3, 4], "label": "申万二级行业", "type": "text", "max_length": 50},
            "sector_up_count": {"line": 3, "label": "行业上涨家数", "unit": "家", "min": 0, "max": 10000, "integer": True},
            "sector_down_count": {"line": 3, "label": "行业下跌家数", "unit": "家", "min": 0, "max": 10000, "integer": True},
            "sector_coverage": {"line": 3, "label": "成分行情覆盖率", "unit": "%", "min": 0, "max": 100},
            "sector_relative_turnover": {"line": 3, "label": "行业相对市场换手", "unit": "倍", "min": 0, "max": 20},
            "sector_member_equal_change": {"line": 3, "label": "成分等权涨跌幅", "unit": "%", "min": -100, "max": 100},
            "sector_change": {"line": 4, "label": "申万官方涨跌幅", "unit": "%", "min": -100, "max": 100},
            "sector_leading_pct": {"line": [3, 4], "label": "行业领涨股涨跌幅", "unit": "%", "min": -100, "max": 100},
            "market_sentiment_score": {"line": 5, "label": "市场情绪温度", "unit": "分", "min": 0, "max": 100},
            "market_seal_rate": {"line": 5, "label": "封板率", "unit": "%", "min": 0, "max": 100},
            "market_amount_billion": {"line": 5, "label": "两市成交额", "unit": "亿元", "min": 0, "max": 10000000},
            "market_recent_average_amount_billion": {"line": 5, "label": "近期平均成交额", "unit": "亿元", "min": 0, "max": 10000000},
            "market_up_count": {"line": 5, "label": "上涨家数", "unit": "家", "min": 0, "max": 10000, "integer": True},
            "market_down_count": {"line": 5, "label": "下跌家数", "unit": "家", "min": 0, "max": 10000, "integer": True},
            "market_limit_up_count": {"line": 5, "label": "涨停家数", "unit": "家", "min": 0, "max": 10000, "integer": True},
            "market_limit_down_count": {"line": 5, "label": "跌停家数", "unit": "家", "min": 0, "max": 10000, "integer": True},
            "index_sh_change": {"line": 6, "label": "上证指数涨跌幅", "unit": "%", "min": -20, "max": 20},
            "index_sz_change": {"line": 6, "label": "深证成指涨跌幅", "unit": "%", "min": -20, "max": 20},
            "index_cy_change": {"line": 6, "label": "创业板指涨跌幅", "unit": "%", "min": -20, "max": 20},
            "note": {"line": [], "label": "补录说明", "type": "text", "max_length": 200},
        }
        if intraday:
            for key in ("stock_seal_amount_million", "stock_open_times"):
                fields.pop(key)
        else:
            for key in ("stock_turnover_relative", "stock_volume_activity_ratio", "sector_relative_turnover"):
                fields.pop(key)
        return fields

    @classmethod
    def _validate_heaven_manual_data(
        cls, raw: Any, market_mode: str
    ) -> dict[str, Any]:
        if raw in (None, ""):
            return {}
        if not isinstance(raw, dict):
            raise ValueError("六爻补录数据格式不正确。")
        schema = cls._heaven_manual_schema(market_mode)
        unknown = set(raw) - set(schema)
        if unknown:
            raise ValueError(f"六爻补录包含未知字段：{next(iter(sorted(unknown)))}")
        values: dict[str, Any] = {}
        for key, value in raw.items():
            if value is None or (isinstance(value, str) and not value.strip()):
                continue
            spec = schema[key]
            if spec.get("type") == "text":
                values[key] = validate_text(value, spec["label"], int(spec["max_length"]))
                continue
            if spec.get("type") == "select":
                text = str(value).strip()
                if text not in spec["options"]:
                    raise ValueError(f"{spec['label']}不在允许范围内。")
                values[key] = text
                continue
            try:
                number = float(value)
            except (TypeError, ValueError) as exc:
                raise ValueError(f"{spec['label']}必须是数字。") from exc
            if number < float(spec["min"]) or number > float(spec["max"]):
                raise ValueError(
                    f"{spec['label']}应在 {spec['min']} 至 {spec['max']} 之间。"
                )
            values[key] = int(number) if spec.get("integer") else number
        return values

    @staticmethod
    def _apply_heaven_manual_data(
        dashboard: dict[str, Any],
        index_context: dict[str, Any],
        sector: dict[str, Any] | None,
        stock: dict[str, Any] | None,
        manual_data: dict[str, Any],
        market_mode: str,
        trade_date: str,
        stock_code: str,
    ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
        dashboard = copy.deepcopy(dashboard)
        index_context = copy.deepcopy(index_context or {})
        sector = copy.deepcopy(sector or {})
        stock = copy.deepcopy(stock or {})
        overview = dashboard.setdefault("overview", {})

        stock_map = {
            "stock_amount_percentile": "amount_percentile",
            "stock_turnover_rate": "turnover_rate",
            "stock_turnover_relative": "turnover_relative",
            "stock_volume_activity_ratio": "volume_activity_ratio",
            "stock_seal_amount_million": "seal_amount_million",
            "stock_open_times": "open_times",
            "stock_change": "change",
            "stock_streak": "streak",
            "stock_status": "status",
        }
        sector_map = {
            "sector_name": "name",
            "sector_up_count": "up_count",
            "sector_down_count": "down_count",
            "sector_coverage": "coverage",
            "sector_relative_turnover": "relative_turnover",
            "sector_member_equal_change": "member_equal_change",
            "sector_change": "change",
            "sector_leading_pct": "leading_pct",
        }
        overview_map = {
            "market_sentiment_score": "sentiment_score",
            "market_seal_rate": "seal_rate",
            "market_amount_billion": "amount_billion",
            "market_recent_average_amount_billion": "recent_average_amount_billion",
            "market_up_count": "up_count",
            "market_down_count": "down_count",
            "market_limit_up_count": "limit_up_count",
            "market_limit_down_count": "limit_down_count",
        }
        for manual_key, target in stock_map.items():
            if manual_key in manual_data:
                stock[target] = manual_data[manual_key]
        for manual_key, target in sector_map.items():
            if manual_key in manual_data:
                sector[target] = manual_data[manual_key]
        for manual_key, target in overview_map.items():
            if manual_key in manual_data:
                overview[target] = manual_data[manual_key]

        if any(key.startswith("stock_") for key in manual_data):
            stock.setdefault("code", stock_code)
            stock.setdefault("name", stock_code or "--")
            stock["_quantitative_mode"] = "intraday" if market_mode == "intraday" else "historical"
            if market_mode == "intraday" and "stock_volume_activity_ratio" in manual_data:
                stock["activity_source"] = "user_supplied"
        if any(key.startswith("sector_") for key in manual_data):
            sector["_quantitative_mode"] = "intraday" if market_mode == "intraday" else "historical"
            sector.setdefault("taxonomy", "sw_l2")

        index_keys = (
            ("index_sh_change", "000001.SH", "上证指数"),
            ("index_sz_change", "399001.SZ", "深证成指"),
            ("index_cy_change", "399006.SZ", "创业板指"),
        )
        rows = {str(row.get("ts_code") or row.get("code") or ""): dict(row) for row in index_context.get("indices") or []}
        for manual_key, code, name in index_keys:
            if manual_key not in manual_data:
                continue
            row = rows.get(code, {"ts_code": code, "name": name})
            row.update({"pct_chg": manual_data[manual_key], "trade_date": trade_date})
            rows[code] = row
        ordered_rows = [rows.get(code) for _, code, _ in index_keys]
        if all(ordered_rows):
            index_context["indices"] = ordered_rows
            changes = [float(row.get("pct_chg") or 0) for row in ordered_rows]
            aggregate = dict(index_context.get("aggregate") or {})
            aggregate["average_pct_chg"] = sum(changes) / 3
            index_context["aggregate"] = aggregate
        return dashboard, index_context, sector, stock

    @classmethod
    def _heaven_line_checks(
        cls,
        trade_date: str,
        dashboard: dict[str, Any],
        recent_history: list[dict[str, Any]],
        index_context: dict[str, Any],
        sector: dict[str, Any],
        stock: dict[str, Any],
        market_mode: str,
        manual_data: dict[str, Any],
    ) -> list[dict[str, Any]]:
        intraday = market_mode == "intraday"
        closed = market_mode == "closed"
        schema = cls._heaven_manual_schema(market_mode)
        required = {
            1: (["stock_amount_percentile", "stock_turnover_relative", "stock_volume_activity_ratio"] if intraday else ["stock_amount_percentile", "stock_turnover_rate", "stock_seal_amount_million", "stock_open_times"]),
            2: ["stock_change", "stock_streak", "stock_status"],
            3: (["sector_name", "sector_up_count", "sector_down_count", "sector_coverage", "sector_relative_turnover"] if intraday else ["sector_name", "sector_up_count", "sector_down_count", "sector_coverage", "sector_member_equal_change", "sector_leading_pct"]),
            4: ["sector_name", "sector_change", "sector_leading_pct"],
            5: ["market_sentiment_score", "market_seal_rate", "market_amount_billion", "market_recent_average_amount_billion", "market_up_count", "market_down_count", "market_limit_up_count", "market_limit_down_count"],
            6: ["index_sh_change", "index_sz_change", "index_cy_change"],
        }
        names = {
            1: ("初爻", "个股内核", "成交活跃、换手与量能"),
            2: ("二爻", "个股外显", "涨跌、连板与状态"),
            3: ("三爻", "行业内核", "行业宽度与成交活跃"),
            4: ("四爻", "行业外显", "行业涨跌与领涨表现"),
            5: ("五爻", "市场内核", "情绪、封板、成交与市场宽度"),
            6: ("上爻", "指数外显", "三大指数当日涨跌"),
        }

        index_date = str(index_context.get("trade_date") or "").replace("-", "")
        index_rows = list(index_context.get("indices") or [])
        index_dates = {str(row.get("trade_date") or "").replace("-", "") for row in index_rows}
        index_issues = []
        if len(index_rows) < 3:
            index_issues.append(f"三大指数仅取得 {len(index_rows)}/3 条行情")
        elif index_date != trade_date or index_dates != {trade_date}:
            actual_dates = "、".join(sorted(value for value in index_dates if value)) or "未知"
            index_issues.append(f"指数实际日期为 {actual_dates}，目标交易日为 {trade_date}")
        elif not index_context.get("precise"):
            index_issues.append("三大指数行情未通过完整性校验")
        elif intraday and not index_context.get("realtime"):
            index_issues.append("盘中缺少可核验的实时指数行情")
        elif not intraday and (index_context.get("realtime") or str(index_context.get("source") or "") != "tushare"):
            index_issues.append("收盘或历史行情不是官方指数日线")

        sector_date = str(sector.get("trade_date") or "").replace("-", "")
        sector_coverage = float(sector.get("coverage") or 0)
        sector_explained_count = int(
            sector.get("explained_count")
            if sector.get("explained_count") is not None
            else sector.get("quote_count") or 0
        )
        sector_explained_coverage = float(
            sector.get("explained_coverage")
            if sector.get("explained_coverage") is not None
            else sector_coverage
        )
        sector_coverage_issue = _sector_coverage_issue(
            int(sector.get("member_count") or 0),
            int(sector.get("quote_count") or 0),
            sector_explained_coverage,
            sector_explained_count,
        )
        sector_common = []
        if not sector:
            sector_common.append("未取得申万二级行业归属")
        elif sector.get("taxonomy") != "sw_l2":
            sector_common.append("行业分类不是申万二级")
        elif sector_date != trade_date:
            sector_common.append("行业行情日期与目标交易日不一致")
        elif intraday and not sector.get("realtime"):
            sector_common.append("盘中行业行情不是申万实时行情")
        elif market_mode == "historical" and sector.get("realtime"):
            sector_common.append("历史行业行情不能使用实时快照")
        elif closed and sector.get("realtime") and not sector.get("finalized"):
            sector_common.append("收盘行业实时行情尚未形成15:00最终快照")
        sector_inner = list(sector_common)
        sector_outer = list(sector_common)
        if not sector.get("inner_precise", sector.get("precise")):
            sector_inner.append(str(sector.get("inner_error") or sector.get("error") or "行业内核数据未通过校验"))
        if not sector.get("outer_precise", sector.get("precise")):
            sector_outer.append(str(sector.get("outer_error") or sector.get("error") or "行业外显数据未通过校验"))
        if sector and sector_coverage_issue and sector_coverage_issue not in sector_inner:
            sector_inner.append(sector_coverage_issue)
        if sector.get("realtime") and not sector.get("relative_turnover"):
            sector_inner.append("缺少行业相对全市场换手活跃度")

        stock_date = str(stock.get("trade_date") or "").replace("-", "")
        stock_common = []
        if not stock.get("code"):
            stock_common.append("尚未载入有效个股")
        elif stock_date != trade_date:
            stock_common.append(f"个股实际日期为 {stock_date or '未知'}，目标交易日为 {trade_date}")
        elif not stock.get("precise"):
            stock_common.append("个股行情未通过完整性校验")
        elif intraday and not stock.get("realtime"):
            stock_common.append("盘中个股行情不是实时行情")
        elif not intraday and (stock.get("realtime") or str(stock.get("data_source") or "") != "tushare"):
            stock_common.append("收盘或历史个股行情不是官方日线")
        stock_inner = list(stock_common)
        if intraday and stock.get("turnover_source") in {None, "", "unavailable"}:
            stock_inner.append("缺少可核验的实时换手率")
        if intraday and stock.get("activity_source") in {None, "", "unavailable"}:
            stock_inner.append("缺少同时间进度量能基准")

        overview = dashboard.get("overview") or {}
        market_key_map = {
            "market_sentiment_score": "sentiment_score", "market_seal_rate": "seal_rate",
            "market_amount_billion": "amount_billion", "market_recent_average_amount_billion": "recent_average_amount_billion",
            "market_up_count": "up_count", "market_down_count": "down_count",
            "market_limit_up_count": "limit_up_count", "market_limit_down_count": "limit_down_count",
        }
        market_issues = []
        for manual_key, source_key in market_key_map.items():
            if source_key == "recent_average_amount_billion":
                history_values = [item.get("amount_billion") for item in recent_history[:-1] if item.get("amount_billion") is not None]
                if source_key not in overview and not history_values:
                    market_issues.append(f"缺少{schema[manual_key]['label']}")
            elif source_key not in overview or overview.get(source_key) is None:
                market_issues.append(f"缺少{schema[manual_key]['label']}")

        automatic_issues = {
            1: stock_inner, 2: stock_common, 3: sector_inner,
            4: sector_outer, 5: market_issues, 6: index_issues,
        }
        limits = list(dashboard.get("limits") or [])
        scores = _market_line_scores(dashboard, recent_history, index_context, sector, stock, limits)

        value_map: dict[str, Any] = {
            "stock_amount_percentile": stock.get("amount_percentile"),
            "stock_turnover_rate": stock.get("turnover_rate"),
            "stock_turnover_relative": stock.get("turnover_relative"),
            "stock_volume_activity_ratio": stock.get("volume_activity_ratio"),
            "stock_seal_amount_million": stock.get("seal_amount_million"),
            "stock_open_times": stock.get("open_times"),
            "stock_change": stock.get("change"), "stock_streak": stock.get("streak"),
            "stock_status": stock.get("status"), "sector_name": sector.get("name"),
            "sector_up_count": sector.get("up_count"), "sector_down_count": sector.get("down_count"),
            "sector_coverage": sector.get("coverage"), "sector_relative_turnover": sector.get("relative_turnover"),
            "sector_member_equal_change": sector.get("member_equal_change"),
            "sector_change": sector.get("change"), "sector_leading_pct": sector.get("leading_pct"),
            "market_sentiment_score": overview.get("sentiment_score"), "market_seal_rate": overview.get("seal_rate"),
            "market_amount_billion": overview.get("amount_billion"),
            "market_recent_average_amount_billion": overview.get("recent_average_amount_billion"),
            "market_up_count": overview.get("up_count"), "market_down_count": overview.get("down_count"),
            "market_limit_up_count": overview.get("limit_up_count"), "market_limit_down_count": overview.get("limit_down_count"),
        }
        history_values = [float(item.get("amount_billion")) for item in recent_history[:-1] if item.get("amount_billion") is not None]
        if value_map["market_recent_average_amount_billion"] is None and history_values:
            value_map["market_recent_average_amount_billion"] = sum(history_values) / len(history_values)
        if value_map["stock_amount_percentile"] is None and not intraday:
            amount = float(stock.get("amount_billion") or 0)
            amounts = [float(item.get("amount_billion") or 0) for item in limits if item.get("amount_billion") is not None]
            value_map["stock_amount_percentile"] = (
                sum(item <= amount for item in amounts) / len(amounts) * 100 if amounts else None
            )
        row_by_code = {str(row.get("ts_code") or row.get("code") or ""): row for row in index_context.get("indices") or []}
        value_map.update({
            "index_sh_change": (row_by_code.get("000001.SH") or {}).get("pct_chg"),
            "index_sz_change": (row_by_code.get("399001.SZ") or {}).get("pct_chg"),
            "index_cy_change": (row_by_code.get("399006.SZ") or {}).get("pct_chg"),
        })

        def missing_value(key: str) -> bool:
            value = value_map.get(key)
            return value is None or (isinstance(value, str) and not value.strip())

        invalid_fields = {
            line_number: {key for key in keys if missing_value(key)}
            for line_number, keys in required.items()
        }
        if stock_common:
            invalid_fields[1].update(required[1])
            invalid_fields[2].update(required[2])
        else:
            if intraday and stock.get("turnover_source") in {None, "", "unavailable"}:
                invalid_fields[1].add("stock_turnover_relative")
            if intraday and stock.get("activity_source") in {None, "", "unavailable"}:
                invalid_fields[1].add("stock_volume_activity_ratio")

        if sector_common:
            invalid_fields[3].update(required[3])
            invalid_fields[4].update(required[4])
        else:
            if not sector.get("inner_precise", sector.get("precise")) or sector_coverage_issue:
                invalid_fields[3].update(key for key in required[3] if key != "sector_name")
            if sector.get("realtime") and not sector.get("relative_turnover"):
                invalid_fields[3].add("sector_relative_turnover")
            # The official SW index supplies only the sector's external change. A valid
            # membership name and member-stock leader remain usable when that quote fails.
            if not sector.get("outer_precise", sector.get("precise")):
                invalid_fields[4].add("sector_change")

        if index_issues:
            invalid_fields[6].update(required[6])

        checks = []
        for line_number in range(1, 7):
            manual_keys = [key for key in required[line_number] if key in manual_data]
            unresolved_fields = [
                key for key in required[line_number]
                if key in invalid_fields[line_number] and key not in manual_data
            ]
            hard_missing_identity = line_number in {1, 2} and not stock.get("code")
            passed = not hard_missing_identity and not unresolved_fields
            status = "manual" if passed and manual_keys else "passed" if passed else "failed"
            reasons = [] if passed else [
                *( ["请先输入并载入股票代码或名称"] if hard_missing_identity else automatic_issues[line_number] ),
                *( ["需补充：" + "、".join(schema[key]["label"] for key in unresolved_fields)] if unresolved_fields else [] ),
            ]
            score = float(scores[line_number - 1]["score"])
            position, layer, formula = names[line_number]
            checks.append({
                "line": line_number, "position": position, "layer": layer, "formula": formula,
                "status": status, "passed": passed, "reasons": reasons,
                "score": round(score, 3) if passed else None,
                "line_value": _score_to_line(score) if passed else None,
                "evidence": scores[line_number - 1]["evidence"] if passed else [],
                "fields": [
                    {
                        "key": key, "label": schema[key]["label"], "unit": schema[key].get("unit", ""),
                        "type": schema[key].get("type", "number"), "options": schema[key].get("options", []),
                        "value": value_map.get(key), "manual": key in manual_data,
                        "required": True, "min": schema[key].get("min"), "max": schema[key].get("max"),
                        "integer": bool(schema[key].get("integer")),
                    }
                    for key in required[line_number]
                ],
            })
        return checks

    def _resolve_heaven_stock_code(self, query: str) -> str:
        raw = validate_text(query, "股票代码或名称", 30, required=True)
        code_match = re.fullmatch(r"(\d{6})(?:\.(?:SH|SZ|BJ))?", raw.upper())
        if code_match:
            return validate_stock_code(code_match.group(1))

        candidates = self.database.search_stock_master(raw)
        exact = [item for item in candidates if str(item.get("name") or "").casefold() == raw.casefold()]
        if not exact and self.configured:
            try:
                rows = self._tushare_client().query(
                    "stock_basic",
                    {"name": raw, "list_status": "L"},
                    "ts_code,symbol,name,industry,market,list_date",
                )
            except TushareError:
                rows = []
            if rows:
                self.database.upsert_stock_master(rows)
                candidates = self.database.search_stock_master(raw)
                exact = [
                    item
                    for item in candidates
                    if str(item.get("name") or "").casefold() == raw.casefold()
                ]

        matches = exact or candidates
        if len(matches) == 1:
            return validate_stock_code(str(matches[0].get("code") or ""))
        if len(matches) > 1:
            choices = "、".join(
                f"{item.get('name') or '--'}（{item.get('code') or '--'}）"
                for item in matches[:5]
            )
            raise ValueError(f"匹配到多只股票：{choices}。请输入六位股票代码。")
        raise ValueError(f"未找到股票“{raw}”，请检查名称或输入六位股票代码。")

    def heaven_setup(
        self,
        trade_date: str,
        sector_name: str = "",
        stock_code: str = "",
        manual_data: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        normalized_date = normalize_date(trade_date)
        dashboard = self.get_dashboard(normalized_date)
        data_date = normalize_date(str(dashboard.get("meta", {}).get("trade_date") or normalized_date))
        recent_history = self.database.snapshot_summaries(data_date, 10)
        market_mode = self._heaven_market_mode(data_date, dashboard)
        manual_data = self._validate_heaven_manual_data(manual_data, market_mode)
        index_context = self._heaven_index_context(data_date, dashboard, market_mode)
        external_stock = None
        normalized_stock_code = ""
        if stock_code.strip():
            normalized_stock_code = self._resolve_heaven_stock_code(stock_code)
            external_stock = self._heaven_stock_context(
                normalized_stock_code,
                data_date,
                dashboard,
                market_mode,
            )
        external_sector = None
        if normalized_stock_code and self.configured:
            external_sector = self._heaven_sector_context(
                normalized_stock_code,
                data_date,
                market_mode,
            )
            if external_sector and external_stock:
                external_stock["sector"] = external_sector.get("name") or external_stock.get("sector")
        dashboard, index_context, external_sector, external_stock = self._apply_heaven_manual_data(
            dashboard,
            index_context,
            external_sector,
            external_stock,
            manual_data,
            market_mode,
            data_date,
            normalized_stock_code,
        )
        if external_sector and external_stock:
            external_stock["sector"] = external_sector.get("name") or external_stock.get("sector")
        sector_input = str((external_sector or {}).get("name") or sector_name.strip())
        if not normalized_stock_code:
            data_checks = []
            chart = {
                "available": False,
                "selection_required": True,
                "data_trade_date": data_date,
                "sector": "",
                "sector_code": "",
                "sector_taxonomy": "",
                "stock": {"code": "", "name": "", "status": ""},
                "quality": {
                    "status": "awaiting_selection",
                    "issues": [],
                    "principle": "",
                    "sources": [],
                },
                "index_context": index_context,
            }
        else:
            data_checks = self._heaven_line_checks(
                data_date,
                dashboard,
                recent_history,
                index_context,
                external_sector or {},
                external_stock or {},
                market_mode,
                manual_data,
            )
            quality_issues = [
                f"{check['position']}·{check['layer']}：{'；'.join(check['reasons'])}"
                for check in data_checks
                if not check["passed"]
            ]
            if quality_issues:
                chart = {
                    "available": False,
                    "selection_required": False,
                    "data_trade_date": data_date,
                    "sector": str((external_sector or {}).get("name") or sector_input or "--"),
                    "sector_code": str((external_sector or {}).get("code") or ""),
                    "sector_taxonomy": str((external_sector or {}).get("taxonomy") or ""),
                    "stock": {
                        "code": normalized_stock_code,
                        "name": str((external_stock or {}).get("name") or "--"),
                        "status": str((external_stock or {}).get("status") or ""),
                    },
                    "quality": {
                        "status": "blocked",
                        "issues": quality_issues,
                        "principle": "六爻任一层缺少同日、同口径的有效数据，本系统不成卦。",
                        "sources": self._heaven_trend_sources(
                            data_date, index_context, external_sector, external_stock
                        ),
                    },
                    "index_context": index_context,
                }
            else:
                chart = build_market_hexagram(
                    dashboard,
                    recent_history,
                    index_context,
                    sector_input,
                    normalized_stock_code,
                    external_stock,
                    external_sector,
                )
                chart["available"] = True
                chart["selection_required"] = False
                manual_active = any(check["status"] == "manual" for check in data_checks)
                chart["quality"] = {
                    "status": "manual" if manual_active else "verified",
                    "issues": [],
                    "principle": (
                        "自动行情与用户补充数据均已通过同一套量化公式校验。"
                        if manual_active
                        else "指数、板块、个股均已通过同日同口径校验。"
                    ),
                    "sources": [
                        *self._heaven_trend_sources(
                            data_date, index_context, external_sector, external_stock
                        ),
                        *([{
                            "lines": "补录爻位",
                            "layer": "用户补充",
                            "realtime": market_mode == "intraday",
                            "detail": str(manual_data.get("note") or "量化数据经原公式重新计算"),
                        }] if manual_active else []),
                    ],
                }
        chart["data_checks"] = data_checks
        chart["manual_data"] = manual_data
        sector_phase_overrides = self.database.list_sector_phase_overrides()
        field = build_five_phase_field(
            normalized_date,
            sector_phase_overrides,
        )
        personal_profile = self.account_personal_field(
            normalized_date,
            field,
            public=True,
        )
        daily_fortune_reading = self.database.latest_heaven_reading(
            self.current_user_id, "fortune", normalized_date
        )
        if self._legacy_truncated_heaven_reading(daily_fortune_reading):
            daily_fortune_reading = None
        return {
            "trade_date": data_date,
            "calendar_date": normalized_date,
            "market_mode": market_mode,
            "chart": chart,
            "field": field,
            "personal_profile": personal_profile,
            "daily_fortune_reading": daily_fortune_reading,
            "sector_phase_overrides": [
                {"name": name, "element": element}
                for name, element in sector_phase_overrides.items()
            ],
            "llm": {
                "configured": self.llm_configured,
                "model": self.llm_primary_model if self.llm_configured else "",
                "fallback_configured": self.llm_fallback_configured,
                "fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
            },
        }

    def _heaven_stock_context(
        self,
        stock_code: str,
        trade_date: str,
        dashboard: dict[str, Any],
        market_mode: str,
    ) -> dict[str, Any]:
        """Return the only stock contract accepted by heaven trend."""
        pool_row = next(
            (
                dict(row) for key in ("limits", "broken", "down_limits")
                for row in dashboard.get(key) or []
                if str(row.get("code") or "") == stock_code
            ),
            {},
        )
        if market_mode == "intraday":
            if self.configured:
                try:
                    quote = self._tushare_client().realtime_stock_quote(
                        tushare_code(stock_code),
                        trade_date,
                    )
                    return {
                        **quote,
                        "status": pool_row.get("status") or "普通",
                        "seal_amount_million": pool_row.get("seal_amount_million") or 0,
                        "open_times": pool_row.get("open_times") or 0,
                        "streak": pool_row.get("streak") or 0,
                        "precise": True,
                    }
                except TushareError:
                    pass
            if pool_row:
                return {
                    **pool_row,
                    "data_source": "dashboard_rt" if dashboard.get("meta", {}).get("realtime") else "dashboard",
                    "trade_date": trade_date,
                    "realtime": bool(dashboard.get("meta", {}).get("realtime")),
                    "precise": False,
                }
            return {
                "code": stock_code,
                "name": "--",
                "sector": "其他",
                "trade_date": trade_date,
                "realtime": False,
                "precise": False,
            }

        detail = self.get_stock_detail(stock_code, trade_date, force=True)
        detail_meta = detail.get("meta") or {}
        stock = detail.get("stock") or {}
        resolved_date = normalize_date(str(detail_meta.get("trade_date") or trade_date))
        source = str(detail_meta.get("source") or "")
        return {
            "code": stock_code,
            "name": stock.get("name") or pool_row.get("name") or "--",
            "sector": stock.get("industry") or pool_row.get("sector") or "其他",
            "status": pool_row.get("status") or "普通",
            "change": stock.get("change") or 0,
            "turnover_rate": stock.get("turnover_rate") or 0,
            "amount_billion": stock.get("amount_billion") or 0,
            "seal_amount_million": pool_row.get("seal_amount_million") or 0,
            "open_times": pool_row.get("open_times") or 0,
            "streak": pool_row.get("streak") or 0,
            "data_source": source,
            "trade_date": resolved_date,
            "realtime": False,
            "precise": source == "tushare" and resolved_date == trade_date,
        }

    @staticmethod
    def _heaven_market_mode(
        trade_date: str,
        dashboard: dict[str, Any],
        now: datetime | None = None,
    ) -> str:
        """区分盘中、今日收盘和历史，避免把 rt_k 数据来源误当成交易状态。"""
        now = now or datetime.now().astimezone()
        if trade_date != now.strftime("%Y%m%d"):
            return "historical"
        meta = dashboard.get("meta") or {}
        status = str(meta.get("market_status") or "").lower()
        local_time = now.time().replace(tzinfo=None)
        if status == "closed" or local_time > datetime.strptime("15:05", "%H:%M").time():
            return "closed"
        if status in {"trading", "auction", "pre_open"} or (
            bool(meta.get("realtime"))
            and local_time >= datetime.strptime("09:15", "%H:%M").time()
        ):
            return "intraday"
        return "historical"

    @staticmethod
    def _heaven_trend_sources(
        trade_date: str,
        index_context: dict[str, Any],
        sector: dict[str, Any] | None,
        stock: dict[str, Any] | None,
    ) -> list[dict[str, Any]]:
        sector = sector or {}
        stock = stock or {}
        return [
            {
                "lines": "五爻、上爻",
                "layer": "指数",
                "source": index_context.get("source") or "unavailable",
                "trade_date": index_context.get("trade_date") or "",
                "realtime": bool(index_context.get("realtime")),
                "detail": f"三大指数 {len(index_context.get('indices') or [])}/3",
            },
            {
                "lines": "三爻、四爻",
                "layer": "行业",
                "source": sector.get("source") or "unavailable",
                "trade_date": sector.get("trade_date") or "",
                "realtime": bool(sector.get("realtime")),
                "detail": (
                    f"申万二级 {sector.get('name') or '--'} {sector.get('code') or '--'} "
                    f"成分覆盖 {int(sector.get('quote_count') or 0)}/{int(sector.get('member_count') or 0)}"
                ),
            },
            {
                "lines": "初爻、二爻",
                "layer": "个股",
                "source": stock.get("data_source") or "unavailable",
                "trade_date": stock.get("trade_date") or trade_date,
                "realtime": bool(stock.get("realtime")),
                "detail": (
                    f"{stock.get('name') or '--'}；换手基准 "
                    f"{stock.get('capital_trade_date') or '--'}"
                ),
            },
        ]

    @staticmethod
    def _heaven_trend_quality_issues(
        trade_date: str,
        dashboard: dict[str, Any],
        index_context: dict[str, Any],
        sector: dict[str, Any] | None,
        stock: dict[str, Any] | None,
        market_mode: str = "historical",
    ) -> list[str]:
        issues: list[str] = []
        intraday = market_mode == "intraday"
        closed = market_mode == "closed"
        if intraday:
            meta = dashboard.get("meta") or {}
            market_status = str(meta.get("market_status") or "")
            now = datetime.now().astimezone()
            try:
                updated_at = datetime.fromisoformat(str(meta.get("updated_at") or ""))
                if updated_at.tzinfo is None:
                    updated_at = updated_at.replace(tzinfo=now.tzinfo)
                snapshot_age = (now - updated_at.astimezone(now.tzinfo)).total_seconds()
            except ValueError:
                snapshot_age = float("inf")
            if market_status in {"trading", "auction", "pre_open"} and snapshot_age > 120:
                issues.append("主行情快照超过2分钟，请点击顶部刷新")
        # 收盘后不再用 dashboard.market_status 作为阻断条件。盘后同步可能将
        # rt_k 快照替换成同日盘后日线而不带该字段；六爻数据本身的日期、
        # 完整性和来源校验已足以判断是否可以成卦。

        index_date = str(index_context.get("trade_date") or "").replace("-", "")
        index_rows = list(index_context.get("indices") or [])
        index_row_dates = {
            str(row.get("trade_date") or "").replace("-", "") for row in index_rows
        }
        if not index_context.get("precise") or len(index_rows) < 3:
            issues.append("指数层缺少三大指数的有效行情")
        elif index_date != trade_date or index_row_dates != {trade_date}:
            issues.append("指数行情与目标交易日不一致")
        elif intraday and not index_context.get("realtime"):
            issues.append("盘中指数层缺少可核验的实时行情")
        elif not intraday and (
            index_context.get("realtime")
            or str(index_context.get("source") or "") != "tushare"
        ):
            issues.append("历史/收盘指数层必须使用 Tushare 官方指数日线")

        sector = sector or {}
        sector_date = str(sector.get("trade_date") or "").replace("-", "")
        sector_coverage = float(sector.get("coverage") or 0)
        sector_explained_count = int(
            sector.get("explained_count")
            if sector.get("explained_count") is not None
            else sector.get("quote_count") or 0
        )
        sector_explained_coverage = float(
            sector.get("explained_coverage")
            if sector.get("explained_coverage") is not None
            else sector_coverage
        )
        sector_coverage_issue = _sector_coverage_issue(
            int(sector.get("member_count") or 0),
            int(sector.get("quote_count") or 0),
            sector_explained_coverage,
            sector_explained_count,
        )
        if not sector:
            issues.append("行业层缺少申万二级行业归属")
        elif sector.get("taxonomy") != "sw_l2":
            issues.append("行业层必须使用申万二级行业分类")
        elif sector_date != trade_date:
            issues.append("行业行情与目标交易日不一致")
        elif intraday and not sector.get("realtime"):
            issues.append("盘中行业层缺少申万实时行情")
        elif market_mode == "historical" and sector.get("realtime"):
            issues.append("历史行业层不能使用实时快照")
        elif closed and sector.get("realtime") and not sector.get("finalized"):
            issues.append("收盘行业层缺少15:00最终快照")
        if not sector.get("inner_precise", sector.get("precise")):
            issues.append("行业内核缺少可核验的成分行情")
        if not sector.get("outer_precise", sector.get("precise")):
            issues.append("行业外显缺少申万官方行情")
        if sector and sector_coverage_issue:
            issues.append(sector_coverage_issue)
        if sector.get("realtime") and not sector.get("relative_turnover"):
            issues.append("行业内核缺少相对全市场换手活跃度")

        stock = stock or {}
        stock_date = str(stock.get("trade_date") or "").replace("-", "")
        if not stock or not stock.get("code"):
            issues.append("个股层尚未载入有效标的")
        elif not stock.get("precise"):
            issues.append("个股层缺少可核验的行情数据")
        elif stock_date != trade_date:
            issues.append("个股行情与目标交易日不一致")
        elif intraday and not stock.get("realtime"):
            issues.append("盘中个股层不是 rt_k 实时行情")
        elif not intraday and (
            stock.get("realtime")
            or str(stock.get("data_source") or "") != "tushare"
        ):
            issues.append("历史/收盘个股层必须使用 Tushare 官方日线")
        if intraday and stock and not stock.get("turnover_source"):
            issues.append("个股内核缺少可核验的实时换手率")
        elif intraday and stock.get("turnover_source") == "unavailable":
            issues.append("个股内核缺少流通股本，无法计算实时换手率")
        if intraday and stock.get("activity_source") == "unavailable":
            issues.append("个股内核缺少近5日量能基准")
        elif intraday and not stock.get("activity_source"):
            issues.append("个股内核缺少同时间进度量能")
        return issues

    def heaven_personal(self, payload: dict[str, Any]) -> dict[str, Any]:
        trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
        field = build_five_phase_field(
            trade_date,
            self.database.list_sector_phase_overrides(),
        )
        personal = self.account_personal_field(trade_date, field, public=True)
        if not personal:
            raise ValueError("请先在账号设置中保存个人命理资料。")
        return personal

    def heaven_hexagram(self, raw_lines: Any) -> dict[str, Any]:
        if not isinstance(raw_lines, list):
            raise ValueError("六爻起卦结果格式不正确。")
        try:
            lines = [int(value) for value in raw_lines]
        except (TypeError, ValueError) as exc:
            raise ValueError("六爻必须由六、七、八、九组成。") from exc
        return hexagram_from_lines(lines)

    def heaven_readings(
        self, mode: str, context_date: str = "", limit: int = 100
    ) -> dict[str, Any]:
        mode = str(mode or "").strip()
        if mode not in {"trend", "fortune", "heart"}:
            raise ValueError("解读记录类型不正确。")
        normalized_date = normalize_date(context_date) if context_date else ""
        return {
            "mode": mode,
            "items": self.database.list_heaven_readings(
                self.current_user_id, mode, normalized_date, limit
            ),
        }

    @staticmethod
    def _heaven_reading_identity(
        mode: str, context_date: str, context: dict[str, Any]
    ) -> tuple[str, str]:
        display_date = DashboardService._display_compact_date(context_date)
        if mode == "trend":
            stock = (context.get("selected_focus") or {}).get("stock") or {}
            code = str(stock.get("code") or "").strip()
            name = str(stock.get("name") or "").strip()
            hexagram = context.get("hexagram") or {}
            transformed = hexagram.get("transformed") or {}
            subject = " ".join(item for item in (code, name) if item) or "观势"
            detail = f"{display_date} · {hexagram.get('name') or '--'} → {transformed.get('name') or '--'}"
            return subject, detail
        if mode == "fortune":
            field = context.get("five_phase_field") or {}
            pillars = field.get("pillars") or {}
            dominant = (field.get("balance") or [{}])[0]
            subject = f"{display_date} 观气"
            detail = (
                f"{pillars.get('year') or '--'}年 · {pillars.get('month') or '--'}月 · "
                f"{pillars.get('day') or '--'}日 · {dominant.get('element') or '--'}气偏显"
            )
            return subject, detail
        hexagram = context.get("hexagram") or {}
        transformed = hexagram.get("transformed") or {}
        return (
            f"{display_date} 观心",
            f"{hexagram.get('name') or '--'} → {transformed.get('name') or '--'}",
        )

    def heaven_interpret(self, payload: dict[str, Any]) -> dict[str, Any]:
        mode = str(payload.get("mode") or "").strip()
        if mode not in {"trend", "fortune", "heart"}:
            raise ValueError("问天解读模式不正确。")
        trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
        if mode == "fortune":
            existing = self.database.latest_heaven_reading(
                self.current_user_id, "fortune", trade_date
            )
            if self._legacy_truncated_heaven_reading(existing):
                self.database.delete_heaven_reading(
                    self.current_user_id, int(existing["id"])
                )
                existing = None
            if existing:
                return {
                    "answer": existing["answer"],
                    "mode": mode,
                    "compiler": "stored",
                    "notice": "",
                    "reading": existing,
                    "reused": True,
                }
        if mode in {"trend", "fortune"}:
            setup = self.heaven_setup(
                trade_date,
                str(payload.get("sector") or ""),
                str(payload.get("stock_code") or ""),
                payload.get("manual_data"),
            )
            if mode == "trend":
                chart = setup["chart"]
                if not chart.get("available"):
                    issues = "；".join((chart.get("quality") or {}).get("issues") or [])
                    raise ValueError(f"观势数据未通过六爻校验，暂不解势：{issues}")
                hexagram_context = json.loads(json.dumps(chart["hexagram"], ensure_ascii=False))
                for line in hexagram_context.get("lines", []):
                    line.pop("evidence", None)
                    line.pop("score", None)
                    line.pop("talent", None)
                    line.pop("layer", None)
                    line.pop("role", None)
                    if not line.get("moving"):
                        line.pop("text", None)
                        line.pop("image", None)
                        line.pop("line_name", None)
                context = {
                    "data_trade_date": setup["trade_date"],
                    "selected_focus": {
                        "sector": chart.get("sector") or "",
                        "stock": chart.get("stock") or {},
                    },
                    "hexagram": hexagram_context,
                    "movement": chart.get("movement") or {},
                }
            else:
                personal_profile = self.account_personal_field(
                    setup["calendar_date"],
                    setup["field"],
                    public=False,
                )
                fortune_field = json.loads(json.dumps(setup["field"], ensure_ascii=False))
                catalog = fortune_field.pop("sector_catalog", [])
                dominant_elements = {
                    item.get("element") for item in fortune_field.get("balance", [])[:2]
                }
                fortune_field["industry_affinity"] = [
                    {
                        "element": group.get("element"),
                        "examples": [
                            item.get("name")
                            for item in group.get("industries", [])[:8]
                            if item.get("name")
                        ],
                    }
                    for group in catalog
                    if group.get("element") in dominant_elements
                ]
                context = {
                    "calendar_date": setup["calendar_date"],
                    "five_phase_field": fortune_field,
                    "personal_profile": personal_profile,
                }
                context_date = setup["calendar_date"]
            if mode == "trend":
                context_date = setup["trade_date"]
        else:
            context = {
                "hexagram": self.heaven_hexagram(payload.get("lines")),
                "ritual": "用户已完成30秒静心、六次三枚铜钱起卦，并在心中察看第一念。问题未输入。",
            }
            context_date = trade_date
        result, compiler = self._call_heaven_agent(mode, context)
        subject, subject_detail = self._heaven_reading_identity(
            mode, context_date, context
        )
        dedupe_key = (
            f"fortune:{context_date}"
            if mode == "fortune"
            else f"{mode}:{context_date}:{secrets.token_urlsafe(12)}"
        )
        reading = self.database.save_heaven_reading(
            self.current_user_id,
            mode,
            context_date,
            subject,
            subject_detail,
            str(result.get("answer") or ""),
            context,
            dedupe_key,
        )
        return {
            **result,
            "mode": mode,
            "compiler": compiler,
            "notice": "智能解读已自动切换可用服务。" if compiler == "fallback" else "",
            "reading": reading,
            "reused": False,
        }

    @staticmethod
    def _legacy_truncated_heaven_reading(reading: dict[str, Any] | None) -> bool:
        return bool(reading and str(reading.get("answer") or "").rstrip().endswith("……"))

    def _call_heaven_agent(self, mode: str, context: dict[str, Any]) -> tuple[dict[str, Any], str]:
        result = self.llm_gateway.call(
            f"heaven_{mode}",
            f"heaven-{mode}-v1",
            lambda profile: interpret_heaven(
                mode,
                context,
                profile.api_key,
                profile.base_url,
                profile.model,
            ),
            (HeavenAgentError,),
        )
        return result.value, result.role

    def _heaven_index_context(
        self,
        trade_date: str,
        dashboard: dict[str, Any],
        market_mode: str = "historical",
    ) -> dict[str, Any]:
        cached = self.database.get_data_snapshot("heaven_indices", trade_date)
        cached_valid = False
        if cached:
            cached_rows = list(cached.get("indices") or [])
            cached_dates = {
                str(row.get("trade_date") or "").replace("-", "")
                for row in cached_rows
            }
            cached_valid = (
                len(cached_rows) == 3
                and cached_dates == {trade_date}
                and bool(cached.get("precise"))
                and not cached.get("realtime")
                and str(cached.get("source") or "") == "tushare"
                and int(cached.get("schema_version") or 0) >= 3
            )
        if market_mode != "intraday" and cached_valid:
            return cached

        if not self.configured:
            error = "Tushare Token 未配置"
        else:
            try:
                client = self._tushare_client()
                if market_mode == "intraday":
                    payload = self._aggregate_index_context(trade_date)
                    payload["schema_version"] = 3
                    return payload
                payload = client.market_indices(trade_date)
                payload["schema_version"] = 3
                if market_mode == "closed":
                    payload["finalized"] = True
                self.database.save_data_snapshot(
                    "heaven_indices",
                    trade_date,
                    str(payload.get("source") or "tushare"),
                    payload,
                )
                return payload
            except Exception as exc:
                error = str(exc)
        overview = dashboard.get("overview") or {}
        up_count = float(overview.get("up_count") or 0)
        down_count = float(overview.get("down_count") or 0)
        breadth = (up_count - down_count) / max(up_count + down_count, 1)
        return {
            "source": "market_breadth_proxy",
            "trade_date": trade_date,
            "realtime": False,
            "precise": False,
            "schema_version": 3,
            "notice": f"指数数据不可用，当前以市场宽度代理：{error}",
            "indices": [],
            "aggregate": {
                "average_pct_chg": round(breadth * 2.5, 3),
                "average_return_5d": 0,
                "average_return_20d": 0,
            },
        }

    def _aggregate_index_context(
        self,
        trade_date: str,
        tushare_error: str = "",
    ) -> dict[str, Any]:
        quotes = self.realtime_aggregator.tencent_indices()
        epochs = [int(item.get("quote_time_epoch") or 0) for item in quotes]
        quote_dates = {
            datetime.fromtimestamp(epoch).astimezone().strftime("%Y%m%d")
            for epoch in epochs if epoch
        }
        if len(quotes) != 3 or quote_dates != {trade_date}:
            raise ValueError("腾讯三大指数日期与目标交易日不一致")
        now = datetime.now().astimezone()
        max_skew = 120 if now.hour >= 15 else 15
        if max(epochs) - min(epochs) > max_skew:
            raise ValueError(f"腾讯三大指数时间差超过{max_skew}秒")

        code_map = {
            "000001": "000001.SH",
            "399001": "399001.SZ",
            "399006": "399006.SZ",
        }
        client = self._tushare_client()
        indices = []
        start_date = (
            datetime.strptime(trade_date, "%Y%m%d") - timedelta(days=20)
        ).strftime("%Y%m%d")
        for quote in quotes:
            ts_code = code_map[str(quote.get("code") or "")]
            history = client.query(
                "index_daily",
                {"ts_code": ts_code, "start_date": start_date, "end_date": trade_date},
                "ts_code,trade_date,close,pct_chg",
            )
            history.sort(key=lambda item: str(item.get("trade_date") or ""))
            completed_closes = [
                float(item.get("close") or 0)
                for item in history
                if str(item.get("trade_date") or "") < trade_date
                and float(item.get("close") or 0) > 0
            ]
            close_5d = (
                completed_closes[-5]
                if len(completed_closes) >= 5
                else completed_closes[0] if completed_closes else 0
            )
            close = float(quote.get("price") or 0)
            indices.append(
                {
                    "ts_code": ts_code,
                    "name": quote.get("name") or ts_code,
                    "trade_date": trade_date,
                    "close": close,
                    "pct_chg": round(float(quote.get("change") or 0), 3),
                    "return_5d": round((close / close_5d - 1) * 100, 3) if close_5d else 0,
                    "return_20d": 0,
                    "amount_billion": float(quote.get("amount_billion") or 0),
                    "quote_time": quote.get("quote_time") or "",
                }
            )
        return {
            "trade_date": trade_date,
            "source": "+".join(
                sorted({str(item.get("source") or "web_quote") for item in quotes})
                + ["tushare_index_daily"]
            ),
            "realtime": True,
            "precise": True,
            "indices": indices,
            "aggregate": {
                "average_pct_chg": round(
                    sum(item["pct_chg"] for item in indices) / len(indices), 3
                ),
                "average_return_5d": round(
                    sum(item["return_5d"] for item in indices) / len(indices), 3
                ),
                "average_return_20d": 0,
            },
            "quote_time_skew_seconds": max(epochs) - min(epochs),
            "notice": (
                "指数实时行情来自腾讯行情，5日趋势来自Tushare历史指数。"
                + (f" Tushare实时指数未使用：{tushare_error}" if tushare_error else "")
            ),
        }

    def _heaven_sector_context(
        self,
        identifier: str,
        trade_date: str,
        market_mode: str = "historical",
    ) -> dict[str, Any] | None:
        """Return the Shenwan L2 sector context for heaven trend.

        观势行业层只使用申万二级行业。外显盘中使用 rt_sw_k、历史使用
        sw_daily；内核独立使用目标日期成分股行情聚合。收盘过渡期在
        sw_daily 入库前接受同日15:00后的 rt_sw_k 收盘快照。
        """
        cache_key = f"{trade_date}:{identifier.strip().lower()}"
        cached = self.database.get_data_snapshot("heaven_sector", cache_key)
        cached_date = str((cached or {}).get("trade_date") or "").replace("-", "")
        cached_valid = bool(
            cached
            and cached_date == trade_date
            and cached.get("taxonomy") == "sw_l2"
            and cached.get("inner_precise", cached.get("precise"))
            and cached.get("outer_precise", cached.get("precise"))
            and not cached.get("realtime")
            and int(cached.get("schema_version") or 0) >= 6
        )
        if market_mode != "intraday" and cached_valid:
            return cached
        if not self.configured:
            return None
        try:
            payload = self._tushare_client().sw_sector_snapshot(
                tushare_code(identifier),
                trade_date,
                realtime_expected=market_mode == "intraday",
                allow_realtime_close=market_mode == "closed",
            )
        except TushareError as exc:
            if cached_valid:
                return cached
            return {
                "name": "",
                "code": "",
                "taxonomy": "sw_l2",
                "source": "tushare",
                "trade_date": trade_date,
                "realtime": market_mode == "intraday",
                "precise": False,
                "inner_precise": False,
                "outer_precise": False,
                "coverage": 0,
                "member_count": 0,
                "quote_count": 0,
                "error": f"申万二级行业数据获取失败：{exc}",
            }
        if not payload.get("realtime") and payload.get("precise"):
            self.database.save_data_snapshot(
                "heaven_sector",
                cache_key,
                str(payload.get("source") or "tushare"),
                payload,
            )
        return payload

    @staticmethod
    def _validate_mentor_history(raw_history: Any) -> list[dict[str, str]]:
        if not isinstance(raw_history, list):
            raise ValueError("问师对话历史格式不正确。")
        history = []
        total_length = 0
        for item in raw_history[-12:]:
            if not isinstance(item, dict) or item.get("role") not in {"user", "assistant"}:
                raise ValueError("问师对话历史包含无效消息。")
            content = str(item.get("content") or "").strip()
            if not content or len(content) > 5000:
                raise ValueError("问师对话历史消息为空或过长。")
            total_length += len(content)
            if total_length > 24_000:
                raise ValueError("问师对话历史过长，请清空后重新提问。")
            history.append({"role": item["role"], "content": content})
        return history

    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)
        )
        regime = self.screener.detect_regime(data_trade_date)
        limits = list(dashboard.get("limits") or [])
        broken = list(dashboard.get("broken") or [])
        down_limits = list(dashboard.get("down_limits") or [])
        yesterday_limits = list(dashboard.get("yesterday_limits") or [])
        all_stocks = limits + broken + down_limits + yesterday_limits
        matched_rows = []
        codes = re.findall(r"(?<!\d)\d{6}(?!\d)", question)[:3]
        for row in all_stocks:
            code = str(row.get("code") or "")
            name = str(row.get("name") or "")
            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:
                detail = self.get_stock_detail(code, data_trade_date)
                stock_details.append(
                    {
                        "stock": detail.get("stock") or {},
                        "moneyflow": detail.get("moneyflow") or {},
                        "recent_prices": (detail.get("prices") or [])[-20:],
                    }
                )
            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 any(keyword in question for keyword in ("龙虎榜", "席位", "机构", "游资")):
            try:
                dragon_payload = self.get_dragon_tiger(data_trade_date)
                rows = list(dragon_payload.get("rows") or [])
                matched_dragon = [row for row in rows if str(row.get("code") or "") in codes]
                leading_dragon = sorted(
                    rows,
                    key=lambda row: abs(float(row.get("net_buy_million") or 0)),
                    reverse=True,
                )[:12]
                dragon_tiger = {
                    "summary": dragon_payload.get("summary") or {},
                    "matched": matched_dragon,
                    "largest_net_flows": leading_dragon,
                }
            except Exception as exc:
                dragon_tiger = {"error": str(exc)}

        context: dict[str, Any] = {
            "data_trade_date": data_trade_date,
            "data_profile": profile,
            "overview": dashboard.get("overview") or {},
            "market_regime": regime,
            "recent_market_history": self.database.snapshot_summaries(data_trade_date, 10),
            "question_matched_stocks": matched_rows[:10],
            "stock_details": stock_details,
        }

        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 "")
        if regime not in REGIMES:
            raise ValueError("市场阶段不支持。")
        strategy_name = validate_text(payload.get("strategy_name"), "策略名称", 60, required=True)
        formula = payload.get("formula") or {}
        requested_mode = str(payload.get("mode") or "").strip()
        if requested_mode and requested_mode not in {"smart", "curated", "quant"}:
            raise ValueError("选股模式不受支持。")
        if requested_mode:
            mode = requested_mode
        else:
            meta = formula.get("meta") if isinstance(formula, dict) else {}
            library = str((meta or {}).get("library") or "")
            category = str((meta or {}).get("category") or "")
            if library == "curated":
                mode = "curated"
            elif library == "quant" or (library == "custom" and category == "量化公式"):
                mode = "quant"
            else:
                mode = "smart"
        realtime_snapshot = None
        dashboard = self.get_dashboard(trade_date)
        if self.configured and dashboard.get("meta", {}).get("realtime"):
            try:
                realtime_snapshot = self._tushare_client().realtime_factor_snapshot(trade_date)
            except TushareError as exc:
                raise ValueError(f"实时选股行情不可用，已停止筛选：{exc}") from exc
        result = self.screener.screen(
            self.current_user_id, trade_date, formula, regime, strategy_name,
            bool(payload.get("run_backtest", True)),
            realtime_snapshot,
            mode,
        )
        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 = self._tushare_client().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"
        if not force:
            cached = self.database.get_data_snapshot(cache_kind, normalized_date)
            if (
                cached
                and cached.get("meta", {}).get("source") == "tushare"
                and cached.get("meta", {}).get("status") == "success"
                and int(cached.get("meta", {}).get("schema_version") or 0) == 3
            ):
                cached["meta"] = {**cached.get("meta", {}), "cached": True}
                return cached
        if self.configured:
            try:
                payload = self._tushare_client().dragon_tiger(normalized_date)
            except TushareError as exc:
                return {
                    "meta": {
                        "requested_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
                        "trade_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
                        "source": "tushare_error",
                        "status": "error",
                        "schema_version": 3,
                        "cached": False,
                        "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
                        "notice": "龙虎榜数据暂不可用，请稍后重试。",
                    },
                    "summary": {
                        "trader_count": 0,
                        "identity_count": 0,
                        "operation_count": 0,
                        "active_stock_count": 0,
                        "seat_net_buy_million": 0,
                        "unclassified_count": 0,
                        "directory_count": 0,
                    },
                    "traders": [],
                    "unclassified_seats": [],
                    "rows": [],
                }
            payload["meta"]["cached"] = False
            if payload.get("meta", {}).get("status") == "success":
                self.database.save_data_snapshot(cache_kind, normalized_date, "tushare", payload)
            return payload

        return {
            "meta": {
            "requested_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
            "trade_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
            "source": "unavailable",
            "status": "unavailable",
            "schema_version": 3,
            "cached": False,
            "notice": "龙虎榜数据暂不可用，请联系管理员检查行情配置。",
            },
            "summary": {
                "trader_count": 0,
                "identity_count": 0,
                "operation_count": 0,
                "active_stock_count": 0,
                "seat_net_buy_million": 0,
                "unclassified_count": 0,
                "directory_count": 0,
            },
            "traders": [],
            "unclassified_seats": [],
            "rows": [],
        }

    def _search_market_directory(self) -> list[dict[str, Any]]:
        cached = self.database.get_data_snapshot("search_directory", "ths") or {}
        cached_items = list(cached.get("items") or [])
        if cached_items and int(cached.get("schema_version") or 0) >= 2:
            return cached_items
        if not self.configured:
            return cached_items

        try:
            rows = self._tushare_client().query(
                "ths_index",
                {},
                "ts_code,name,count,exchange,list_date,type",
            )
        except TushareError:
            return cached_items

        items = []
        for row in rows:
            mapping = THS_SEARCH_TYPES.get(str(row.get("type") or "").upper())
            code = str(row.get("ts_code") or "").strip().upper()
            name = str(row.get("name") or "").strip()
            if not mapping or not code or not name or str(row.get("exchange") or "").upper() != "A":
                continue
            entity_type, subtitle = mapping
            items.append(
                {
                    "id": code,
                    "code": code,
                    "name": name,
                    "type": entity_type,
                    "subtitle": subtitle,
                    "member_count": int(float(row.get("count") or 0)),
                }
            )
        if items:
            self.database.save_data_snapshot(
                "search_directory", "ths", "tushare", {"schema_version": 2, "items": items}
            )
        return items

    @staticmethod
    def _search_match_score(item: dict[str, Any], query: str) -> tuple[int, int, str]:
        name = str(item.get("name") or "").casefold()
        code = str(item.get("code") or item.get("id") or "").casefold()
        needle = query.casefold()
        if code == needle:
            rank = 0
        elif name == needle:
            rank = 1
        elif code.startswith(needle):
            rank = 2
        elif name.startswith(needle):
            rank = 3
        else:
            rank = 4
        return rank, len(name), code

    def search_entities(self, query: str, trade_date: str) -> dict[str, Any]:
        needle = str(query or "").strip()
        normalized_date = normalize_date(trade_date)
        groups: dict[str, list[dict[str, Any]]] = {
            "stocks": [],
            "sectors": [],
            "themes": [],
            "indices": [],
        }
        if not needle:
            return {"query": "", "trade_date": normalized_date, "groups": groups}

        stocks = []
        for row in self.database.search_stock_master(needle, 12):
            stocks.append(
                {
                    "id": str(row.get("code") or ""),
                    "code": str(row.get("code") or ""),
                    "name": str(row.get("name") or "--"),
                    "type": "stock",
                    "type_label": SEARCH_TYPE_LABELS["stock"],
                    "industry": str(row.get("industry") or "其他"),
                    "market": str(row.get("market") or ""),
                    "subtitle": " · ".join(
                        part for part in (str(row.get("industry") or ""), str(row.get("market") or "")) if part
                    ) or "A股",
                }
            )
        groups["stocks"] = stocks[:8]

        market_items = list(self._search_market_directory()) + [dict(item) for item in SEARCH_INDEXES]
        matched = [
            item for item in market_items
            if needle.casefold() in str(item.get("name") or "").casefold()
            or needle.casefold() in str(item.get("code") or "").casefold()
        ]
        matched.sort(key=lambda item: self._search_match_score(item, needle))
        group_keys = {"sector": "sectors", "theme": "themes", "index": "indices"}
        for item in matched:
            group_key = group_keys.get(str(item.get("type") or ""))
            if not group_key or len(groups[group_key]) >= 8:
                continue
            groups[group_key].append(
                {
                    **item,
                    "type_label": SEARCH_TYPE_LABELS[str(item["type"])],
                }
            )
        return {"query": needle, "trade_date": normalized_date, "groups": groups}

    def get_search_detail(
        self, entity_type: str, identifier: str, trade_date: str
    ) -> dict[str, Any]:
        entity_type = str(entity_type or "").strip().lower()
        identifier = str(identifier or "").strip().upper()
        normalized_date = normalize_date(trade_date)
        if entity_type not in {"sector", "theme", "index"}:
            raise ValueError("搜索详情类型不支持。")
        if not re.fullmatch(r"[A-Z0-9.]{3,24}", identifier):
            raise ValueError("搜索详情标识无效。")
        if not self.configured:
            raise ValueError("行情数据源尚未配置。")

        if entity_type == "index":
            index_basic = next((item for item in SEARCH_INDEXES if item["id"] == identifier), None)
            if not index_basic:
                raise ValueError("暂不支持该指数详情。")
            return self._index_search_detail(index_basic, normalized_date)

        directory = self._search_market_directory()
        basic = next(
            (
                item for item in directory
                if item.get("id") == identifier and item.get("type") == entity_type
            ),
            None,
        )
        if not basic:
            raise ValueError("未找到对应的板块或题材。")
        return self._ths_search_detail(basic, normalized_date)

    def get_intraday_chart(
        self, entity_type: str, identifier: str
    ) -> dict[str, Any]:
        entity_type = str(entity_type or "").strip().lower()
        identifier = str(identifier or "").strip().upper()
        if entity_type == "stock":
            code = validate_stock_code(identifier)
            chart = self.chart_data.stock_intraday(code)
            type_label = SEARCH_TYPE_LABELS["stock"]
        elif entity_type == "index":
            basic = next((item for item in SEARCH_INDEXES if item["id"] == identifier), None)
            if not basic:
                raise ValueError("暂不支持该指数分时行情。")
            chart = self.chart_data.index_intraday(identifier)
            type_label = SEARCH_TYPE_LABELS["index"]
        elif entity_type in {"sector", "theme"}:
            basic = next(
                (
                    item for item in self._search_market_directory()
                    if item.get("id") == identifier and item.get("type") == entity_type
                ),
                None,
            )
            if not basic:
                raise ValueError("未找到对应的板块或题材。")
            chart = self.chart_data.board_intraday(identifier, str(basic.get("name") or ""))
            type_label = SEARCH_TYPE_LABELS[entity_type]
        else:
            raise ValueError("分时行情类型不支持。")

        return {
            "meta": {
                "trade_date": str(chart.get("trade_date") or ""),
                "previous_close": float(chart.get("previous_close") or 0),
            },
            "entity": {
                "id": identifier,
                "code": str(chart.get("code") or identifier),
                "name": str(chart.get("name") or ""),
                "type": entity_type,
                "type_label": type_label,
            },
            "points": list(chart.get("points") or []),
        }

    def _ths_search_detail(
        self, basic: dict[str, Any], trade_date: str
    ) -> dict[str, Any]:
        client = self._tushare_client()
        resolved_date, _ = client.resolve_trade_context(trade_date)
        end = datetime.strptime(resolved_date, "%Y%m%d")
        start_date = (end - timedelta(days=190)).strftime("%Y%m%d")
        identifier = str(basic["id"])
        snapshot = client.sector_snapshot(identifier, resolved_date)
        rows = client.query(
            "ths_daily",
            {"ts_code": identifier, "start_date": start_date, "end_date": resolved_date},
            "ts_code,trade_date,open,high,low,close,pct_change,vol,turnover_rate,total_mv,float_mv",
        )
        rows.sort(key=lambda item: str(item.get("trade_date") or ""))
        series = [
            {
                "trade_date": self._display_compact_date(str(row.get("trade_date") or "")),
                "open": float(row.get("open") or 0),
                "high": float(row.get("high") or 0),
                "low": float(row.get("low") or 0),
                "close": float(row.get("close") or 0),
                "change": float(row.get("pct_change") or 0),
                "volume": float(row.get("vol") or 0),
                "turnover_rate": float(row.get("turnover_rate") or 0),
            }
            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(
            snapshot.get("change")
            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
            else latest.get("turnover_rate") or 0
        )
        metrics = [
            {"label": "涨跌幅", "value": round(change, 2), "unit": "%", "tone": "change"},
            {"label": "换手率", "value": round(turnover_rate, 2), "unit": "%"},
            {"label": "成份数量", "value": int(float(basic.get("member_count") or 0)), "unit": "只"},
        ]
        up_count = int(float(snapshot.get("up_count") or 0))
        down_count = int(float(snapshot.get("down_count") or 0))
        if up_count or down_count:
            metrics.extend(
                [
                    {"label": "上涨家数", "value": up_count, "unit": "家"},
                    {"label": "下跌家数", "value": down_count, "unit": "家"},
                ]
            )
        leader = str(snapshot.get("leader") or "").strip()
        if leader and leader != "--":
            metrics.extend(
                [
                    {"label": "领涨标的", "value": leader, "unit": ""},
                    {"label": "领涨幅", "value": round(float(snapshot.get("leading_pct") or 0), 2), "unit": "%", "tone": "change"},
                ]
            )
        return {
            "meta": {
                "trade_date": self._display_compact_date(resolved_date),
                "realtime": bool(snapshot.get("realtime")),
            },
            "entity": {
                "id": identifier,
                "code": identifier,
                "name": str(snapshot.get("name") or basic.get("name") or "--"),
                "type": str(basic.get("type") or "sector"),
                "type_label": SEARCH_TYPE_LABELS[str(basic.get("type") or "sector")],
                "subtitle": str(basic.get("subtitle") or ""),
                "value": float(latest.get("close") or 0),
                "change": change,
            },
            "series": series,
            "metrics": metrics,
        }

    def _index_search_detail(
        self, basic: dict[str, Any], trade_date: str
    ) -> dict[str, Any]:
        client = self._tushare_client()
        resolved_date, _ = client.resolve_trade_context(trade_date)
        payload = (
            client.realtime_market_indices(resolved_date)
            if client.should_use_realtime(trade_date, resolved_date)
            else client.market_indices(resolved_date, 90)
        )
        current = next(
            (item for item in payload.get("indices") or [] if item.get("ts_code") == basic["id"]),
            None,
        )
        if not current:
            raise ValueError("该指数暂无可用行情。")
        end = datetime.strptime(resolved_date, "%Y%m%d")
        rows = client.query(
            "index_daily",
            {
                "ts_code": basic["id"],
                "start_date": (end - timedelta(days=190)).strftime("%Y%m%d"),
                "end_date": resolved_date,
            },
            "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
        )
        rows.sort(key=lambda item: str(item.get("trade_date") or ""))
        series = [
            {
                "trade_date": self._display_compact_date(str(row.get("trade_date") or "")),
                "open": float(row.get("open") or 0),
                "high": float(row.get("high") or 0),
                "low": float(row.get("low") or 0),
                "close": float(row.get("close") or 0),
                "change": float(row.get("pct_chg") or 0),
                "volume": float(row.get("vol") or 0),
            }
            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)),
                "realtime": bool(payload.get("realtime")),
            },
            "entity": {
                **basic,
                "type_label": SEARCH_TYPE_LABELS["index"],
                "value": latest_close,
                "change": latest_change,
            },
            "series": series,
            "metrics": [
                {"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": "亿"},
            ],
        }

    def get_stock_detail(
        self, code: str, trade_date: str, force: bool = False
    ) -> dict[str, Any]:
        code = validate_stock_code(code)
        normalized_date = normalize_date(trade_date)
        cache_key = f"{code}:{normalized_date}"
        if not force:
            cached = self.database.get_data_snapshot("stock_detail", cache_key)
            if cached and str((cached.get("meta") or {}).get("source") or "") != "demo":
                if not self._stock_detail_cache_needs_refresh(cached, normalized_date):
                    cached["meta"] = {**cached.get("meta", {}), "cached": True}
                    return self._prepare_stock_detail(cached, code, normalized_date)

        name, sector = self._stock_identity(code, normalized_date)
        source = "tushare"
        if self.configured:
            try:
                payload = self._tushare_client().stock_detail(
                    tushare_code(code), normalized_date
                )
                if not payload.get("prices"):
                    raise TushareError("No price history returned")
            except TushareError as exc:
                payload = self.database.get_latest_data_snapshot(
                    "stock_detail", f"{code}:", cache_key, exclude_source="demo"
                )
                if not payload:
                    raise ValueError(f"暂无 {code} 的真实行情数据：{exc}") from exc
                payload = copy.deepcopy(payload)
                payload["meta"] = {
                    **payload.get("meta", {}),
                    "cached": True,
                    "notice": "最新行情暂不可用，已沿用最近真实收盘数据。",
                }
                return self._prepare_stock_detail(payload, code, normalized_date)
        else:
            payload = self.database.get_latest_data_snapshot(
                "stock_detail", f"{code}:", cache_key, exclude_source="demo"
            )
            if not payload:
                raise ValueError(f"暂无 {code} 的真实行情数据，请等待后台完成首次同步。")
            payload = copy.deepcopy(payload)
            payload["meta"] = {
                **payload.get("meta", {}),
                "cached": True,
                "notice": "公共行情尚未配置，已沿用最近真实收盘数据。",
            }
            return self._prepare_stock_detail(payload, code, normalized_date)
        payload["meta"]["source"] = source
        payload["meta"]["cached"] = False
        self.database.save_data_snapshot("stock_detail", cache_key, source, payload)
        return self._prepare_stock_detail(payload, code, normalized_date)

    @staticmethod
    def _stock_detail_bar_date(payload: dict[str, Any]) -> str:
        prices = list(payload.get("prices") or [])
        return str((prices[-1] if prices else {}).get("trade_date") or "").replace("-", "")

    def _stock_detail_cache_needs_refresh(
        self, payload: dict[str, Any], requested_date: str
    ) -> bool:
        now = datetime.now().astimezone()
        return (
            requested_date == now.strftime("%Y%m%d")
            and now.time().replace(tzinfo=None) >= dt_time(15, 0)
            and self._stock_detail_bar_date(payload) < requested_date
        )

    def _prepare_stock_detail(
        self, payload: dict[str, Any], code: str, requested_date: str
    ) -> dict[str, Any]:
        result = copy.deepcopy(payload)
        now = datetime.now().astimezone()
        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
        result = self._sanitize_stock_detail_prices(result, now)
        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:]}",
            }
        today = now.strftime("%Y%m%d")
        should_merge = (
            requested_date == today
            and actual_date <= today
            and now.weekday() < 5
            and now.time().replace(tzinfo=None) >= dt_time(9, 30)
        )
        if should_merge:
            quote = self._ifind_realtime_stock_quote(code)
            if quote and self._valid_realtime_stock_quote(quote, today):
                self._merge_realtime_stock_detail(result, quote, requested_date)
            elif self.configured and actual_date < today:
                client = self._tushare_client()
                try:
                    resolved_date, _ = client.resolve_trade_context(requested_date)
                    if resolved_date == today:
                        quote = client.realtime_stock_quote(tushare_code(code), requested_date)
                        if self._valid_realtime_stock_quote(quote, today):
                            self._merge_realtime_stock_detail(result, quote, requested_date)
                except TushareError:
                    pass
        return self._enrich_stock_detail(result)

    @staticmethod
    def _sanitize_stock_detail_prices(
        payload: dict[str, Any], market_now: datetime
    ) -> dict[str, Any]:
        result = copy.deepcopy(payload)
        raw_prices = list(result.get("prices") or [])
        raw_latest_date = str(
            (raw_prices[-1] if raw_prices else {}).get("trade_date") or ""
        ).replace("-", "")
        prices = []
        for bar in raw_prices:
            open_price = float(bar.get("open") or 0)
            high = float(bar.get("high") or 0)
            low = float(bar.get("low") or 0)
            close = float(bar.get("close") or 0)
            if (
                open_price > 0
                and high >= max(open_price, close)
                and 0 < low <= min(open_price, close)
                and close > 0
            ):
                prices.append(bar)

        today = market_now.strftime("%Y%m%d")
        market_open = (
            market_now.weekday() < 5
            and market_now.time().replace(tzinfo=None) >= dt_time(9, 30)
        )
        if prices and str(prices[-1].get("trade_date") or "").replace("-", "") == today:
            current = prices[-1]
            has_market_activity = (
                float(current.get("volume") or 0) > 0
                or float(current.get("amount_billion") or 0) > 0
            )
            if not market_open or not has_market_activity:
                prices.pop()

        if raw_latest_date == today and (
            not prices
            or str(prices[-1].get("trade_date") or "").replace("-", "") != today
        ):
            result["meta"] = {**(result.get("meta") or {}), "realtime": False}

        result["prices"] = prices
        if prices:
            latest = prices[-1]
            stock = dict(result.get("stock") or {})
            stock.update(
                {
                    "price": float(latest.get("close") or 0),
                    "change": float(latest.get("change") or 0),
                    "amount_billion": float(latest.get("amount_billion") or 0),
                }
            )
            result["stock"] = stock
        return result

    @staticmethod
    def _valid_realtime_stock_quote(quote: dict[str, Any], trade_date: str) -> bool:
        price = float(quote.get("price") or 0)
        open_price = float(quote.get("open") or 0)
        high = float(quote.get("high") or 0)
        low = float(quote.get("low") or 0)
        volume = float(quote.get("volume") or 0)
        amount = float(quote.get("amount_billion") or 0)
        quote_date = str(quote.get("quote_time") or "")[:10].replace("-", "")
        return (
            price > 0
            and open_price > 0
            and high >= max(open_price, price)
            and 0 < low <= min(open_price, price)
            and (volume > 0 or amount > 0)
            and (not quote_date or quote_date == trade_date)
        )

    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),
            "quote_time": str(row.get("time") or ""),
        }

    @staticmethod
    def _merge_realtime_stock_detail(
        payload: dict[str, Any], quote: dict[str, Any], trade_date: str
    ) -> None:
        display_date = f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:]}"
        realtime_bar = {
            "trade_date": display_date,
            "open": quote["open"],
            "high": quote["high"],
            "low": quote["low"],
            "close": quote["price"],
            "change": quote["change"],
            "volume": quote["volume"] if quote.get("volume_unit") == "lots" else quote["volume"] / 100,
            "amount_billion": quote["amount_billion"],
            "realtime": True,
        }
        prices = list(payload.get("prices") or [])
        if prices and str(prices[-1].get("trade_date") or "").replace("-", "") == trade_date:
            prices[-1] = realtime_bar
        else:
            prices.append(realtime_bar)
        payload["prices"] = prices[-90:]
        stock = dict(payload.get("stock") or {})
        stock.update(
            {
                "name": quote["name"],
                "industry": quote["sector"],
                "price": quote["price"],
                "change": quote["change"],
                "amount_billion": quote["amount_billion"],
                "turnover_rate": quote["turnover_rate"],
            }
        )
        payload["stock"] = stock
        payload["meta"] = {
            **(payload.get("meta") or {}),
            "trade_date": display_date,
            "realtime": True,
            "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
        }

    def get_stock_preview(
        self, code: str, trade_date: str, force: bool = False
    ) -> dict[str, Any]:
        code = validate_stock_code(code)
        # 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]] = []
        intraday_status = "unavailable"
        intraday_notice = "分时行情暂不可用。"

        intraday_trade_date = ""
        intraday_previous_close = 0.0
        try:
            intraday = self.chart_data.stock_intraday(code)
            intraday_points = list(intraday.get("points") or [])
            intraday_trade_date = str(intraday.get("trade_date") or "")
            intraday_previous_close = float(intraday.get("previous_close") or 0)
            if intraday_points:
                intraday_status = "available"
                intraday_notice = ""
            else:
                intraday_status = "empty"
                intraday_notice = "最近交易日暂无分时数据。"
        except ChartDataError:
            intraday_status = "unavailable"
            intraday_notice = "分时行情暂不可用，请稍后重试。"

        prices = list(detail.get("prices") or [])[-60:]
        stock = dict(detail.get("stock") or {"code": code})
        realtime = bool(detail_meta.get("realtime"))
        return {
            "meta": {
                "trade_date": resolved_date,
                "source": detail_meta.get("source") or "unavailable",
                "notice": detail_meta.get("notice") or "",
                "intraday_status": intraday_status,
                "intraday_notice": intraday_notice,
                "intraday_trade_date": intraday_trade_date,
                "intraday_previous_close": intraday_previous_close,
                "realtime": realtime,
                "refresh_interval_seconds": 10 if realtime else 0,
            },
            "stock": stock,
            "prices": prices,
            "intraday": intraday_points,
        }

    def save_reason(self, trade_date: str, code: str, reason: str) -> None:
        normalized_date = normalize_date(trade_date)
        code = validate_stock_code(code)
        reason = reason.strip()
        if not reason or len(reason) > 200:
            raise ValueError("涨停原因应为 1 至 200 个字符。")
        self.database.save_reason_override(normalized_date, code, reason)

    def backfill(self, start_date: str, end_date: str) -> list[dict[str, Any]]:
        start = datetime.strptime(normalize_date(start_date), "%Y%m%d").date()
        end = datetime.strptime(normalize_date(end_date), "%Y%m%d").date()
        if start > end:
            raise ValueError("开始日期不能晚于结束日期。")
        weekdays = []
        current = start
        while current <= end:
            if current.weekday() < 5:
                weekdays.append(current)
            current += timedelta(days=1)
        if len(weekdays) > 15:
            raise ValueError("单次最多回补 15 个工作日。")
        results = []
        for day in weekdays:
            dashboard = self.sync_dashboard(day.strftime("%Y%m%d"))
            results.append(
                {
                    "requested_date": day.isoformat(),
                    "trade_date": dashboard["meta"]["trade_date"],
                    "source": dashboard["meta"]["source"],
                    "records": self._record_count(dashboard),
                }
            )
        return results

    def _stock_identity(self, code: str, trade_date: str) -> tuple[str, str]:
        snapshot = self.database.get_snapshot(trade_date) or {}
        for key in ("limits", "broken", "down_limits"):
            for row in snapshot.get(key) or []:
                if str(row.get("code")) == code:
                    return row.get("name") or "--", row.get("sector") or "其他"
        for item in self.database.list_watchlist(self.current_user_id):
            if item["code"] == code:
                return item["name"], item["sector"] or "其他"
        return "--", "其他"

    def _enrich_stock_detail(self, payload: dict[str, Any]) -> dict[str, Any]:
        result = dict(payload)
        stock = dict(payload.get("stock") or {})
        code = str(stock.get("code") or "")
        watched = {
            item["code"]: item
            for item in self.database.list_watchlist(self.current_user_id)
        }
        stock["watchlist"] = watched.get(code)
        result["stock"] = stock
        result["notes"] = self.database.list_notes(self.current_user_id, code=code)
        return result

    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
        for key in ("limits", "broken", "down_limits"):
            for row in dashboard.get(key) or []:
                if row.get("code") in overrides:
                    row["reason"] = overrides[row["code"]]
                    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
        self.jobs.submit(
            "market.ifind-event-enrichment",
            f"{trade_date}:v1",
            lambda: self._refresh_ifind_event_enrichment(trade_date),
            {"trade_date": trade_date, "trigger": "dashboard-enrichment"},
        )

    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)
        rows = payload.get("rows") or []
        for row in rows:
            for institution in row.get("institutions") or []:
                institution["alias"] = aliases.get(institution.get("seat_name", ""), "")
        traders: dict[tuple[str, str], dict[str, Any]] = {}
        unclassified: dict[str, dict[str, Any]] = {}
        seen_operations: set[tuple[Any, ...]] = set()
        builtin_aliases = {
            "国泰海通证券股份有限公司南京太平南路证券营业部": "作手新一",
        }

        for row in rows:
            for institution in row.get("institutions") or []:
                seat_name = str(institution.get("seat_name") or "未知席位").strip()
                saved_alias = str(institution.get("alias") or "").strip()
                builtin_alias = builtin_aliases.get(seat_name, "")
                if saved_alias or builtin_alias:
                    identity_name = saved_alias or builtin_alias
                    identity_type = "trader"
                    recognized = True
                    identity_source = "manual" if saved_alias else "builtin"
                elif "机构专用" in seat_name:
                    identity_name = "机构专用"
                    identity_type = "institution"
                    recognized = True
                    identity_source = "system"
                elif "沪股通专用" in seat_name or "深股通专用" in seat_name:
                    identity_name = "北向资金"
                    identity_type = "channel"
                    recognized = True
                    identity_source = "system"
                else:
                    identity_name = seat_name
                    identity_type = "unclassified"
                    recognized = False
                    identity_source = "raw"

                buy = round(float(institution.get("buy_million") or 0), 2)
                sell = round(float(institution.get("sell_million") or 0), 2)
                net_buy = round(float(institution.get("net_buy_million") or 0), 2)
                operation_key = (row.get("code"), seat_name, buy, sell, net_buy)
                if operation_key in seen_operations:
                    continue
                seen_operations.add(operation_key)

                group_key = (identity_type, identity_name)
                group = traders.setdefault(
                    group_key,
                    {
                        "name": identity_name,
                        "identity_type": identity_type,
                        "identity_source": identity_source,
                        "recognized": recognized,
                        "buy_million": 0.0,
                        "sell_million": 0.0,
                        "net_buy_million": 0.0,
                        "seat_names": set(),
                        "stock_codes": set(),
                        "operations": [],
                    },
                )
                group["buy_million"] += buy
                group["sell_million"] += sell
                group["net_buy_million"] += net_buy
                group["seat_names"].add(seat_name)
                group["stock_codes"].add(str(row.get("code") or ""))
                group["operations"].append(
                    {
                        "code": row.get("code") or "",
                        "name": row.get("name") or "--",
                        "change": row.get("change") or 0,
                        "direction": "买入" if net_buy > 0 else "卖出" if net_buy < 0 else "持平",
                        "buy_million": buy,
                        "sell_million": sell,
                        "net_buy_million": net_buy,
                        "reason": row.get("reason") or "--",
                        "seat_name": seat_name,
                        "seat_alias": identity_name if recognized else "",
                    }
                )

                if not recognized:
                    pending = unclassified.setdefault(
                        seat_name,
                        {
                            "seat_name": seat_name,
                            "stock_codes": set(),
                            "operation_count": 0,
                            "buy_million": 0.0,
                            "sell_million": 0.0,
                            "net_buy_million": 0.0,
                        },
                    )
                    pending["stock_codes"].add(str(row.get("code") or ""))
                    pending["operation_count"] += 1
                    pending["buy_million"] += buy
                    pending["sell_million"] += sell
                    pending["net_buy_million"] += net_buy

        type_order = {"trader": 0, "institution": 1, "channel": 2, "unclassified": 3}
        aggregated = list(traders.values())
        aggregated.sort(
            key=lambda item: (
                type_order.get(item["identity_type"], 9),
                -abs(item["net_buy_million"]),
                item["name"],
            )
        )
        for index, group in enumerate(aggregated, start=1):
            group["id"] = f"identity-{index}"
            group["buy_million"] = round(group["buy_million"], 2)
            group["sell_million"] = round(group["sell_million"], 2)
            group["net_buy_million"] = round(group["net_buy_million"], 2)
            group["seat_count"] = len(group.pop("seat_names"))
            group["stock_count"] = len(group.pop("stock_codes"))
            group["operation_count"] = len(group["operations"])
            group["operations"].sort(
                key=lambda item: abs(float(item.get("net_buy_million") or 0)), reverse=True
            )

        pending_seats = list(unclassified.values())
        for pending in pending_seats:
            pending["stock_count"] = len(pending.pop("stock_codes"))
            pending["buy_million"] = round(pending["buy_million"], 2)
            pending["sell_million"] = round(pending["sell_million"], 2)
            pending["net_buy_million"] = round(pending["net_buy_million"], 2)
        pending_seats.sort(key=lambda item: abs(item["net_buy_million"]), reverse=True)

        operation_count = sum(item["operation_count"] for item in aggregated)
        active_stocks = {
            operation["code"] for item in aggregated for operation in item["operations"]
        }
        seat_net_buy = round(sum(item["net_buy_million"] for item in aggregated), 2)
        result["rows"] = rows
        result["traders"] = aggregated
        result["unclassified_seats"] = pending_seats
        result["summary"] = {
            **(payload.get("summary") or {}),
            "trader_count": sum(item["identity_type"] == "trader" for item in aggregated),
            "identity_count": len(aggregated),
            "operation_count": operation_count,
            "active_stock_count": len(active_stocks),
            "seat_net_buy_million": seat_net_buy,
            "unclassified_count": len(pending_seats),
        }
        return result

    def _with_storage(self, dashboard: dict[str, Any], cached: bool) -> dict[str, Any]:
        result = dict(dashboard)
        result["meta"] = {
            **dashboard.get("meta", {}),
            "storage": "sqlite",
            "cached": cached,
        }
        return result

    @staticmethod
    def _record_count(dashboard: dict[str, Any]) -> int:
        return sum(
            len(dashboard.get(key) or [])
            for key in ("limits", "broken", "down_limits", "yesterday_limits")
        )


SERVICE = DashboardService()


class RequestHandler(BaseHTTPRequestHandler):
    server_version = "XiaobaiReviewWeb/0.8"

    def do_GET(self) -> None:
        parsed = urlparse(self.path)
        if parsed.path == "/api/health":
            self.send_json(
                {
                    "ok": True,
                    "storage": "sqlite",
                    "account_required": True,
                    "time": datetime.now().astimezone().isoformat(timespec="seconds"),
                }
            )
            return
        if parsed.path == "/api/auth/me":
            self.auth_me()
            return
        if parsed.path.startswith("/api/"):
            if not self.require_auth():
                return
            if not self.require_access("GET", parsed.path):
                return
        if parsed.path == "/api/admin/settings":
            self.send_json(
                {"ok": True, **SERVICE.system_status(), "users": SERVICE.admin_users()}
            )
            return
        if parsed.path == "/api/account/status":
            self.send_json({"ok": True, **SERVICE.status()})
            return
        if parsed.path == "/api/alerts":
            query = parse_qs(parsed.query)
            try:
                self.send_json(
                    SERVICE.alert_center(
                        query.get("status", ["all"])[0],
                        query.get("as_of", [date.today().isoformat()])[0],
                    )
                )
            except ValueError as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/trades":
            query = parse_qs(parsed.query)
            try:
                self.send_json(
                    SERVICE.trade_entries(
                        query.get("start_date", [""])[0],
                        query.get("end_date", [""])[0],
                        query.get("code", [""])[0],
                    )
                )
            except ValueError as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/assistant/messages":
            self.send_json({"items": SERVICE.assistant_messages()})
            return
        if parsed.path == "/api/dashboard":
            query = parse_qs(parsed.query)
            trade_date = query.get("trade_date", [date.today().isoformat()])[0]
            try:
                self.send_json(SERVICE.get_dashboard(trade_date, False))
            except ValueError as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            except Exception as exc:
                self.send_json({"error": f"数据加载失败：{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)
            return
        if parsed.path == "/api/auction":
            query = parse_qs(parsed.query)
            try:
                self.send_json(
                    SERVICE.auction_center(
                        query.get("trade_date", [date.today().isoformat()])[0],
                        query.get("force", ["0"])[0] == "1",
                    )
                )
            except (ValueError, TushareError) as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/themes":
            query = parse_qs(parsed.query)
            try:
                self.send_json(
                    SERVICE.theme_library(
                        query.get("trade_date", [date.today().isoformat()])[0],
                        query.get("force", ["0"])[0] == "1",
                    )
                )
            except (ValueError, TushareError) as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/themes/detail":
            query = parse_qs(parsed.query)
            try:
                self.send_json(
                    SERVICE.theme_detail(
                        query.get("code", [""])[0],
                        query.get("trade_date", [date.today().isoformat()])[0],
                    )
                )
            except (ValueError, TushareError) as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/popularity":
            query = parse_qs(parsed.query)
            try:
                self.send_json(
                    SERVICE.popularity(
                        query.get("trade_date", [date.today().isoformat()])[0],
                        query.get("force", ["0"])[0] == "1",
                    )
                )
            except (ValueError, TushareError) as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/realtime-aggregate/health":
            query = parse_qs(parsed.query)
            try:
                self.send_json(
                    {
                        "ok": True,
                        "aggregate": SERVICE.realtime_aggregate_health(
                            query.get("sector", [""])[0]
                        ),
                    }
                )
            except ValueError as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/sentiment/history":
            query = parse_qs(parsed.query)
            trade_date = query.get("trade_date", [date.today().isoformat()])[0]
            try:
                limit = int(query.get("limit", ["20"])[0])
                self.send_json(SERVICE.sentiment_history(trade_date, limit))
            except (TypeError, ValueError) as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/rotation/history":
            query = parse_qs(parsed.query)
            trade_date = query.get("trade_date", [date.today().isoformat()])[0]
            try:
                self.send_json(SERVICE.rotation_history(trade_date, 9))
            except (TypeError, ValueError) as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/rotation/members":
            query = parse_qs(parsed.query)
            try:
                self.send_json(
                    SERVICE.rotation_sector_members(
                        query.get("trade_date", [date.today().isoformat()])[0],
                        query.get("sector", [""])[0],
                    )
                )
            except (TypeError, ValueError) as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/dragon-tiger":
            query = parse_qs(parsed.query)
            trade_date = query.get("trade_date", [date.today().isoformat()])[0]
            force = query.get("force", ["0"])[0] == "1"
            try:
                self.send_json(SERVICE.get_dragon_tiger(trade_date, force))
            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]
            trade_date = query.get("trade_date", [date.today().isoformat()])[0]
            try:
                self.send_json(SERVICE.search_entities(search_query, trade_date))
            except ValueError as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/search/detail":
            query = parse_qs(parsed.query)
            entity_type = query.get("type", [""])[0]
            identifier = query.get("id", [""])[0]
            trade_date = query.get("trade_date", [date.today().isoformat()])[0]
            try:
                self.send_json(
                    SERVICE.get_search_detail(entity_type, identifier, trade_date)
                )
            except ValueError as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            except TushareError as exc:
                self.send_json({"error": f"行情加载失败：{exc}"}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/chart/intraday":
            query = parse_qs(parsed.query)
            entity_type = query.get("type", [""])[0]
            identifier = query.get("id", [""])[0]
            try:
                self.send_json(SERVICE.get_intraday_chart(entity_type, identifier))
            except (ValueError, ChartDataError) as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        stock_preview_match = re.fullmatch(r"/api/stock/(\d{6})/preview", parsed.path)
        if stock_preview_match:
            query = parse_qs(parsed.query)
            trade_date = query.get("trade_date", [date.today().isoformat()])[0]
            force = query.get("force", ["0"])[0] == "1"
            try:
                self.send_json(
                    SERVICE.get_stock_preview(stock_preview_match.group(1), trade_date, force)
                )
            except ValueError as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        stock_match = re.fullmatch(r"/api/stock/(\d{6})", parsed.path)
        if stock_match:
            query = parse_qs(parsed.query)
            trade_date = query.get("trade_date", [date.today().isoformat()])[0]
            force = query.get("force", ["0"])[0] == "1"
            try:
                self.send_json(SERVICE.get_stock_detail(stock_match.group(1), trade_date, force))
            except ValueError as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/watchlist":
            query = parse_qs(parsed.query)
            try:
                self.send_json(
                    SERVICE.review_watchlist(
                        query.get("trade_date", [date.today().isoformat()])[0]
                    )
                )
            except ValueError as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/notes":
            query = parse_qs(parsed.query)
            code = query.get("code", [""])[0]
            trade_date = query.get("trade_date", [""])[0].replace("-", "")
            scope = query.get("scope", ["all"])[0]
            if scope not in {"all", "daily", "stock"}:
                self.send_json({"error": "复盘记录范围不支持。"}, HTTPStatus.BAD_REQUEST)
                return
            self.send_json(
                {
                    "items": SERVICE.database.list_notes(
                        SERVICE.current_user_id, code, trade_date, scope
                    )
                }
            )
            return
        if parsed.path == "/api/seat-aliases":
            self.send_json({"items": SERVICE.database.list_seat_aliases()})
            return
        if parsed.path == "/api/screener/setup":
            query = parse_qs(parsed.query)
            trade_date = query.get("trade_date", [date.today().isoformat()])[0]
            try:
                self.send_json(SERVICE.screener_setup(trade_date))
            except ValueError as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/screener/tracking":
            query = parse_qs(parsed.query)
            try:
                self.send_json(
                    SERVICE.screener_tracking(int(query.get("limit", ["12"])[0]))
                )
            except (TypeError, ValueError) as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/mentors/setup":
            query = parse_qs(parsed.query)
            trade_date = query.get("trade_date", [date.today().isoformat()])[0]
            try:
                self.send_json(SERVICE.mentor_setup(trade_date))
            except ValueError as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/mentors/messages":
            query = parse_qs(parsed.query)
            try:
                self.send_json(
                    {
                        "items": SERVICE.mentor_messages(
                            query.get("mentor_id", [""])[0],
                            query.get("trade_date", [date.today().isoformat()])[0],
                        )
                    }
                )
            except ValueError as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/heaven/readings":
            query = parse_qs(parsed.query)
            try:
                self.send_json(
                    SERVICE.heaven_readings(
                        query.get("mode", [""])[0],
                        query.get("context_date", [""])[0],
                        int(query.get("limit", ["100"])[0]),
                    )
                )
            except (TypeError, ValueError) as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/heaven/setup":
            query = parse_qs(parsed.query)
            trade_date = query.get("trade_date", [date.today().isoformat()])[0]
            sector_name = query.get("sector", [""])[0]
            stock_code = query.get("stock_code", [""])[0]
            manual_data = None
            manual_text = query.get("manual_data", [""])[0]
            if manual_text:
                try:
                    manual_data = json.loads(manual_text)
                except json.JSONDecodeError:
                    self.send_json({"error": "六爻补录数据格式不正确。"}, HTTPStatus.BAD_REQUEST)
                    return
            try:
                self.send_json(
                    SERVICE.heaven_setup(
                        trade_date,
                        sector_name,
                        stock_code,
                        manual_data,
                    )
                )
            except ValueError as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        self.serve_static(parsed.path)

    def do_POST(self) -> None:
        parsed = urlparse(self.path)
        if parsed.path == "/api/auth/register":
            self.auth_register()
            return
        if parsed.path == "/api/auth/login":
            self.auth_login()
            return
        if not self.require_auth() or not self.require_csrf():
            return
        if not self.require_access("POST", parsed.path):
            return
        if parsed.path == "/api/auth/logout":
            self.auth_logout()
            return
        if parsed.path == "/api/account/birth-profile":
            self.save_birth_profile()
            return
        if parsed.path == "/api/account/password":
            self.change_password()
            return
        alert_read_match = re.fullmatch(r"/api/alerts/(\d+)/read", parsed.path)
        if alert_read_match:
            self.send_json(
                {"ok": True, **SERVICE.mark_alert_read(int(alert_read_match.group(1)))}
            )
            return
        if parsed.path == "/api/alerts/read-all":
            body = self.read_json_body(True)
            self.send_json(
                {"ok": True, **SERVICE.mark_all_alerts_read(str(body.get("as_of") or ""))}
            )
            return
        if parsed.path == "/api/alerts":
            self.save_alert()
            return
        if parsed.path == "/api/trades":
            self.save_trade_entry()
            return
        if parsed.path == "/api/assistant/chat":
            self.stream_assistant_chat()
            return
        if parsed.path == "/api/admin/settings":
            self.save_system_settings()
            return
        if parsed.path == "/api/admin/settings/test":
            self.test_system_llm_settings()
            return
        if parsed.path == "/api/admin/membership":
            self.save_membership()
            return
        if parsed.path == "/api/admin/refresh":
            self.start_background_refresh()
            return
        if parsed.path == "/api/watchlist":
            self.save_watchlist()
            return
        if parsed.path == "/api/notes":
            self.save_note()
            return
        if parsed.path == "/api/reasons":
            self.save_reason()
            return
        if parsed.path == "/api/seat-aliases":
            self.save_seat_alias()
            return
        if parsed.path == "/api/heaven/sector-phases":
            self.save_sector_phase_override()
            return
        if parsed.path == "/api/backfill":
            self.backfill_data()
            return
        if parsed.path == "/api/screener/sync":
            self.sync_screener_data()
            return
        if parsed.path == "/api/screener/compile":
            self.compile_screener_strategy()
            return
        if parsed.path == "/api/screener/strategies":
            self.save_screener_strategy()
            return
        if parsed.path == "/api/screener/run":
            self.run_screener()
            return
        if parsed.path == "/api/screener/tracking":
            try:
                result = SERVICE.add_screener_tracking(self.read_json_body())
                self.send_json({"ok": True, **result})
            except (ValueError, json.JSONDecodeError) as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/screener/tracking/refresh":
            self.refresh_screener_tracking()
            return
        if parsed.path == "/api/mentors/preferences":
            try:
                result = SERVICE.save_mentor_preferences(self.read_json_body())
                self.send_json({"ok": True, **result})
            except (ValueError, json.JSONDecodeError) as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/mentors/chat":
            self.stream_mentor_chat()
            return
        if parsed.path == "/api/heaven/hexagram":
            self.heaven_hexagram()
            return
        if parsed.path == "/api/heaven/personal":
            self.heaven_personal()
            return
        if parsed.path == "/api/heaven/interpret":
            self.heaven_interpret()
            return
        self.send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND)

    def do_DELETE(self) -> None:
        parsed = urlparse(self.path)
        if not self.require_auth() or not self.require_csrf():
            return
        if not self.require_access("DELETE", parsed.path):
            return
        if parsed.path == "/api/account/birth-profile":
            deleted = SERVICE.database.delete_user_birth_profile(SERVICE.current_user_id)
            self.send_json({"ok": True, "deleted": deleted})
            return
        if parsed.path == "/api/assistant/messages":
            deleted = SERVICE.clear_assistant_messages()
            self.send_json({"ok": True, "deleted": deleted})
            return
        if parsed.path == "/api/mentors/messages":
            query = parse_qs(parsed.query)
            try:
                deleted = SERVICE.clear_mentor_messages(
                    query.get("mentor_id", [""])[0],
                    query.get("trade_date", [date.today().isoformat()])[0],
                )
                self.send_json({"ok": True, "deleted": deleted})
            except ValueError as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        strategy_match = re.fullmatch(r"/api/screener/strategies/(\d+)", parsed.path)
        if strategy_match:
            try:
                result = SERVICE.delete_screener_strategy(int(strategy_match.group(1)))
                self.send_json({"ok": True, **result})
            except ValueError as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        tracking_match = re.fullmatch(r"/api/screener/tracking/(\d+)", parsed.path)
        if tracking_match:
            result = SERVICE.remove_screener_tracking(int(tracking_match.group(1)))
            self.send_json({"ok": True, **result})
            return
        watchlist_match = re.fullmatch(r"/api/watchlist/(\d{6})", parsed.path)
        if watchlist_match:
            deleted = SERVICE.database.delete_watchlist(
                SERVICE.current_user_id, watchlist_match.group(1)
            )
            self.send_json({"ok": True, "deleted": deleted})
            return
        note_match = re.fullmatch(r"/api/notes/(\d+)", parsed.path)
        if note_match:
            deleted = SERVICE.database.delete_note(
                SERVICE.current_user_id, int(note_match.group(1))
            )
            self.send_json({"ok": True, "deleted": deleted})
            return
        alert_match = re.fullmatch(r"/api/alerts/(\d+)", parsed.path)
        if alert_match:
            self.send_json(
                {"ok": True, **SERVICE.delete_alert(int(alert_match.group(1)))}
            )
            return
        trade_match = re.fullmatch(r"/api/trades/(\d+)", parsed.path)
        if trade_match:
            self.send_json(
                {"ok": True, **SERVICE.delete_trade_entry(int(trade_match.group(1)))}
            )
            return
        heaven_reading_match = re.fullmatch(r"/api/heaven/readings/(\d+)", parsed.path)
        if heaven_reading_match:
            deleted = SERVICE.database.delete_heaven_reading(
                SERVICE.current_user_id, int(heaven_reading_match.group(1))
            )
            self.send_json({"ok": True, "deleted": deleted})
            return
        sector_phase_match = re.fullmatch(r"/api/heaven/sector-phases/(.+)", parsed.path)
        if sector_phase_match:
            name = unquote(sector_phase_match.group(1)).strip()
            deleted = SERVICE.database.delete_sector_phase_override(name)
            self.send_json({"ok": True, "deleted": deleted})
            return
        self.send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND)

    def auth_register(self) -> None:
        try:
            body = self.read_json_body()
            result = SERVICE.register_account(
                str(body.get("username") or ""),
                str(body.get("password") or ""),
            )
            self.send_json(
                {
                    "ok": True,
                    "authenticated": True,
                    "user": result["user"],
                    "csrf_token": result["csrf_token"],
                },
                HTTPStatus.CREATED,
                {"Set-Cookie": self.session_cookie(result["session_token"])},
            )
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def auth_login(self) -> None:
        try:
            body = self.read_json_body()
            result = SERVICE.login_account(
                str(body.get("username") or ""),
                str(body.get("password") or ""),
            )
            self.send_json(
                {
                    "ok": True,
                    "authenticated": True,
                    "user": result["user"],
                    "csrf_token": result["csrf_token"],
                },
                headers={"Set-Cookie": self.session_cookie(result["session_token"])},
            )
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.UNAUTHORIZED)

    def auth_me(self) -> None:
        if not self.require_auth(send_error=False):
            self.send_json(
                {
                    "ok": True,
                    "authenticated": False,
                    "registration_required": SERVICE.database.count_users() == 0,
                }
            )
            return
        self.send_json(
            {
                "ok": True,
                "authenticated": True,
                "user": {
                    "id": int(self.auth_user["id"]),
                    "username": str(self.auth_user["username"]),
                    "role": str(self.auth_user.get("role") or "user"),
                    "membership": SERVICE.membership(),
                },
                "csrf_token": str(self.auth_user["csrf_token"]),
            }
        )

    def auth_logout(self) -> None:
        raw_token = self.session_token()
        if raw_token:
            SERVICE.database.delete_session(token_hash(raw_token))
        self.send_json(
            {"ok": True},
            headers={"Set-Cookie": self.session_cookie("", clear=True)},
        )

    def save_birth_profile(self) -> None:
        try:
            body = self.read_json_body()
            personal = SERVICE.save_birth_profile(body)
            self.send_json({"ok": True, "personal": personal})
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def change_password(self) -> None:
        try:
            body = self.read_json_body()
            current = str(body.get("current_password") or "")
            new = str(body.get("new_password") or "")
            confirmation = str(body.get("confirm_password") or "")
            if new != confirmation:
                raise ValueError("两次输入的新密码不一致。")
            SERVICE.change_password(current, new)
            self.send_json({"ok": True})
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def save_alert(self) -> None:
        try:
            body = self.read_json_body()
            self.send_json({"ok": True, **SERVICE.create_alert(body)}, HTTPStatus.CREATED)
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def save_trade_entry(self) -> None:
        try:
            body = self.read_json_body()
            self.send_json({"ok": True, **SERVICE.save_trade_entry(body)}, HTTPStatus.CREATED)
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def stream_assistant_chat(self) -> None:
        try:
            body = self.read_json_body()
            stream = SERVICE.assistant_stream(body)
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        self.send_response(HTTPStatus.OK)
        self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
        self.send_header("Cache-Control", "no-cache, no-transform")
        self.send_header("X-Accel-Buffering", "no")
        self.send_header("Connection", "close")
        self.end_headers()
        try:
            for chunk in stream:
                self._write_stream_event({"type": "delta", "content": chunk})
            self._write_stream_event({"type": "done"})
        except (ValueError, ReviewAssistantError) as exc:
            self._write_stream_event({"type": "error", "error": str(exc)})
        except (BrokenPipeError, ConnectionResetError):
            pass
        finally:
            self.close_connection = True

    def _write_stream_event(self, payload: dict[str, Any]) -> None:
        self.wfile.write(
            (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
        )
        self.wfile.flush()

    def session_token(self) -> str:
        cookie = SimpleCookie()
        try:
            cookie.load(self.headers.get("Cookie", ""))
        except Exception:
            return ""
        morsel = cookie.get(SESSION_COOKIE)
        return morsel.value if morsel else ""

    def require_auth(self, send_error: bool = True) -> bool:
        raw_token = self.session_token()
        user = SERVICE.database.session_user(token_hash(raw_token)) if raw_token else None
        if not user:
            if send_error:
                self.send_json({"error": "请先登录。"}, HTTPStatus.UNAUTHORIZED)
            return False
        self.auth_user = user
        SERVICE.bind_user(int(user["id"]))
        return True

    def require_csrf(self) -> bool:
        supplied = self.headers.get("X-CSRF-Token", "")
        expected = str(getattr(self, "auth_user", {}).get("csrf_token") or "")
        if not supplied or not secrets.compare_digest(supplied, expected):
            self.send_json({"error": "请求校验失败，请刷新页面后重试。"}, HTTPStatus.FORBIDDEN)
            return False
        return True

    def require_admin(self) -> bool:
        if str(getattr(self, "auth_user", {}).get("role") or "user") != "admin":
            self.send_json({"error": "需要管理员权限。"}, HTTPStatus.FORBIDDEN)
            return False
        return True

    def require_member(self) -> bool:
        if SERVICE.membership()["active"]:
            return True
        self.send_json(
            {"error": "该功能仅对有效会员开放，请联系管理员开通会员。", "code": "membership_required"},
            HTTPStatus.FORBIDDEN,
        )
        return False

    def require_access(self, method: str, path: str) -> bool:
        route = ROUTES.resolve(method, path)
        if route is None:
            self.send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND)
            return False
        role = route.access
        if role == "public":
            return True
        if role == "admin":
            return self.require_admin()
        if role == "member":
            return self.require_member()
        return True

    def session_cookie(self, value: str, clear: bool = False) -> str:
        max_age = 0 if clear else SESSION_MAX_AGE
        cookie = (
            f"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}"
        )
        if self.headers.get("X-Forwarded-Proto", "").lower() == "https":
            cookie += "; Secure"
        return cookie

    def save_llm_settings(self) -> None:
        try:
            body = self.read_json_body()
            SERVICE.save_llm_settings(
                body.get("primary") or {},
                body.get("fallback") or {},
                bool(body.get("fallback_enabled")),
            )
            self.send_json(
                {
                    "ok": True,
                    "configured": SERVICE.llm_configured,
                    "model": SERVICE.llm_primary_model,
                    "fallback_configured": SERVICE.llm_fallback_configured,
                    "fallback_model": SERVICE.llm_fallback_model,
                }
            )
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def save_llm_mode(self) -> None:
        try:
            body = self.read_json_body()
            SERVICE.save_llm_mode(str(body.get("mode") or "auto"))
            self.send_json({"ok": True, "llm_access": SERVICE.llm_access_status()})
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def save_system_settings(self) -> None:
        try:
            result = SERVICE.save_system_settings(self.read_json_body())
            self.send_json({"ok": True, **result})
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def test_system_llm_settings(self) -> None:
        try:
            body = self.read_json_body()
            result = SERVICE.test_system_llm_profile(
                str(body.get("model_id") or ""), body.get("profile") or {}
            )
            self.send_json({"ok": True, "result": result})
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def save_membership(self) -> None:
        try:
            SERVICE.update_membership(self.read_json_body())
            self.send_json({"ok": True, "users": SERVICE.admin_users()})
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def start_background_refresh(self) -> None:
        try:
            body = self.read_json_body(allow_empty=True)
            started = SERVICE.request_background_sync(
                str(body.get("trade_date") or date.today().isoformat())
            )
            self.send_json(
                {
                    "ok": True,
                    "started": started,
                    "message": "后台刷新已开始" if started else "已有后台刷新任务正在运行",
                },
                HTTPStatus.ACCEPTED,
            )
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def test_llm_settings(self) -> None:
        try:
            body = self.read_json_body()
            role = str(body.get("role") or "")
            profile = body.get("profile") or {}
            result = SERVICE.test_llm_profile(role, profile)
            self.send_json({"ok": True, "result": result})
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def save_watchlist(self) -> None:
        try:
            body = self.read_json_body()
            code = validate_stock_code(str(body.get("code", "")))
            name = validate_text(body.get("name"), "股票名称", 30, required=True)
            sector = validate_text(body.get("sector"), "所属板块", 50)
            color = str(body.get("color") or "red")
            if color not in {"red", "blue", "green", "amber"}:
                raise ValueError("标记颜色不支持。")
            remark = validate_text(body.get("remark"), "跟踪备注", 240)
            SERVICE.database.save_watchlist(
                SERVICE.current_user_id, code, name, sector, color, remark
            )
            self.send_json(
                {
                    "ok": True,
                    "items": SERVICE.database.list_watchlist(SERVICE.current_user_id),
                }
            )
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def save_note(self) -> None:
        try:
            body = self.read_json_body()
            code = str(body.get("code") or "").strip()
            if code:
                code = validate_stock_code(code)
            stock_name = validate_text(body.get("stock_name"), "股票名称", 30)
            trade_date = normalize_date(str(body.get("trade_date") or date.today().isoformat()))
            summary = validate_text(body.get("summary"), "盘面摘要", 500)
            content = validate_text(body.get("content"), "复盘内容", 5000)
            plan = validate_text(body.get("plan"), "明日计划", 2000)
            if not summary and not content and not plan:
                raise ValueError("每日复盘内容不能全部为空。")
            raw_id = body.get("id")
            note_id = int(raw_id) if raw_id else None
            saved_id = SERVICE.database.save_note(
                SERVICE.current_user_id,
                code,
                stock_name,
                trade_date,
                content,
                plan,
                note_id,
                summary=summary,
            )
            self.send_json({"ok": True, "id": saved_id})
        except (ValueError, TypeError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def save_reason(self) -> None:
        try:
            body = self.read_json_body()
            SERVICE.save_reason(
                str(body.get("trade_date") or ""),
                str(body.get("code") or ""),
                str(body.get("reason") or ""),
            )
            self.send_json({"ok": True})
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def save_seat_alias(self) -> None:
        try:
            body = self.read_json_body()
            seat_name = validate_text(body.get("seat_name"), "席位名称", 200, required=True)
            alias = validate_text(body.get("alias"), "席位别名", 50, required=True)
            SERVICE.database.save_seat_alias(seat_name, alias)
            self.send_json({"ok": True})
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def save_sector_phase_override(self) -> None:
        try:
            body = self.read_json_body()
            name = validate_text(body.get("name"), "行业或题材名称", 50, required=True)
            element = str(body.get("element") or "").strip()
            if element not in {"木", "火", "土", "金", "水"}:
                raise ValueError("五行归类必须是木、火、土、金或水。")
            SERVICE.database.save_sector_phase_override(name, element)
            self.send_json({"ok": True})
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def backfill_data(self) -> None:
        try:
            body = self.read_json_body()
            results = SERVICE.backfill(
                str(body.get("start_date") or ""),
                str(body.get("end_date") or ""),
            )
            self.send_json({"ok": True, "results": results})
        except ValueError as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
        except Exception as exc:
            self.send_json({"error": f"历史回补失败：{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)

    def sync_screener_data(self) -> None:
        try:
            body = self.read_json_body()
            result = SERVICE.sync_screener_data(
                str(body.get("trade_date") or date.today().isoformat()),
                int(body.get("lookback") or 45),
            )
            self.send_json({"ok": True, "result": result})
        except ValueError as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
        except Exception as exc:
            self.send_json({"error": f"因子数据同步失败：{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)

    def compile_screener_strategy(self) -> None:
        try:
            body = self.read_json_body()
            result = SERVICE.compile_screener_strategy(
                str(body.get("prompt") or ""), str(body.get("regime") or "")
            )
            self.send_json({"ok": True, "strategy": result})
        except ValueError as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def save_screener_strategy(self) -> None:
        try:
            body = self.read_json_body()
            result = SERVICE.save_screener_strategy(body)
            self.send_json({"ok": True, **result})
        except ValueError as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def run_screener(self) -> None:
        try:
            body = self.read_json_body()
            result = SERVICE.run_screener(body)
            self.send_json({"ok": True, "result": result})
        except ValueError as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
        except Exception as exc:
            self.send_json({"error": f"选股执行失败：{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)

    def refresh_screener_tracking(self) -> None:
        try:
            body = self.read_json_body(True)
            trade_date = str(body.get("trade_date") or date.today().isoformat())
            self.send_json({"ok": True, **SERVICE.refresh_screener_tracking(trade_date)})
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
        except Exception as exc:
            self.send_json({"error": f"跟踪刷新失败：{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)

    def stream_mentor_chat(self) -> None:
        try:
            body = self.read_json_body()
            stream = SERVICE.mentor_stream(body)
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        self.send_response(HTTPStatus.OK)
        self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
        self.send_header("Cache-Control", "no-cache, no-transform")
        self.send_header("X-Accel-Buffering", "no")
        self.send_header("Connection", "close")
        self.end_headers()
        try:
            for event in stream:
                self._write_stream_event(event)
            self._write_stream_event({"type": "done"})
        except (ValueError, MentorAgentError) as exc:
            self._write_stream_event({"type": "error", "error": str(exc)})
        except (BrokenPipeError, ConnectionResetError):
            pass
        finally:
            self.close_connection = True

    def heaven_hexagram(self) -> None:
        try:
            body = self.read_json_body()
            result = SERVICE.heaven_hexagram(body.get("lines"))
            self.send_json({"ok": True, "hexagram": result})
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def heaven_personal(self) -> None:
        try:
            body = self.read_json_body()
            result = SERVICE.heaven_personal(body)
            self.send_json({"ok": True, "personal": result})
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def heaven_interpret(self) -> None:
        try:
            body = self.read_json_body()
            result = SERVICE.heaven_interpret(body)
            self.send_json({"ok": True, **result})
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

    def read_json_body(self, allow_empty: bool = False) -> dict[str, Any]:
        length = int(self.headers.get("Content-Length", "0"))
        if length == 0 and allow_empty:
            return {}
        if length <= 0 or length > 65536:
            raise ValueError("请求内容为空或过大。")
        return json.loads(self.rfile.read(length).decode("utf-8"))

    def serve_static(self, request_path: str) -> None:
        relative = unquote(request_path).lstrip("/") or "index.html"
        candidate = (STATIC_DIR / relative).resolve()
        try:
            candidate.relative_to(STATIC_DIR.resolve())
        except ValueError:
            self.send_error(HTTPStatus.FORBIDDEN)
            return
        if not candidate.is_file():
            candidate = STATIC_DIR / "index.html"
        try:
            content = candidate.read_bytes()
        except OSError:
            self.send_error(HTTPStatus.NOT_FOUND)
            return
        content_type = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream"
        if content_type.startswith("text/") or content_type in {"application/javascript", "application/json"}:
            content_type += "; charset=utf-8"
        self.send_response(HTTPStatus.OK)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(content)))
        self.send_header("Cache-Control", "no-cache")
        self.end_headers()
        self.wfile.write(content)

    def send_json(
        self,
        payload: dict[str, Any],
        status: HTTPStatus = HTTPStatus.OK,
        headers: dict[str, str] | None = None,
    ) -> None:
        request_id = getattr(self, "_correlation_id", "")
        if not request_id:
            request_id = correlation_id(self.headers.get("X-Request-ID", ""))
            self._correlation_id = request_id
        payload = normalize_error_payload(payload, status, request_id)
        content = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(content)))
        self.send_header("Cache-Control", "no-store")
        self.send_header("X-Request-ID", request_id)
        for name, value in (headers or {}).items():
            self.send_header(name, value)
        self.end_headers()
        self.wfile.write(content)

    def log_message(self, format_string: str, *args: Any) -> None:
        print(f"[{self.log_date_time_string()}] {format_string % args}")


def main() -> None:
    parser = argparse.ArgumentParser(description="Xiaobai stock review web application")
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=8765)
    args = parser.parse_args()
    server = ThreadingHTTPServer((args.host, args.port), RequestHandler)
    print(f"Xiaobai Review Web is running at http://{args.host}:{args.port}")
    print("Press Ctrl+C to stop.")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        SERVICE._background_stop.set()
        server.server_close()


if __name__ == "__main__":
    main()
