From 4002f096f47ace3cddb2f07d9e0d1f99be8a7cfc Mon Sep 17 00:00:00 2001 From: leefer Date: Fri, 31 Jul 2026 00:42:06 +0800 Subject: [PATCH] migration: preserve startup accounts and system slice --- app/app_config.py | 130 +- app/backend/application.py | 5422 +++++++++++++++ app/backend/bootstrap/__init__.py | 20 +- app/backend/bootstrap/config.py | 129 + app/backend/bootstrap/runtime.py | 27 + app/backend/bootstrap/settings.py | 4 +- app/backend/data/policy.py | 2 +- app/backend/data/quality.py | 2 +- app/backend/features/accounts/__init__.py | 24 + app/backend/features/accounts/http.py | 110 + app/backend/features/accounts/repository.py | 236 + app/backend/features/accounts/security.py | 71 + app/backend/features/accounts/service.py | 256 + app/backend/features/alerts/service.py | 2 +- app/backend/features/review/trade_journal.py | 2 +- app/backend/features/system/__init__.py | 3 + app/backend/features/system/http.py | 41 + app/backend/features/system/repository.py | 29 + app/backend/http/__init__.py | 3 +- app/backend/http/handler.py | 146 + app/backend/http/router.py | 2 +- app/backend/jobs/registry.py | 2 +- app/database.py | 253 +- app/security.py | 72 +- app/server.py | 5868 +---------------- app/tests/test_account_access.py | 2 +- app/tests/test_data_gateway.py | 4 +- app/tests/test_market_mode.py | 2 +- app/tests/test_mentor_registry.py | 2 +- app/tests/test_preservation_slice_accounts.py | 116 + app/tests/test_stock_detail_realtime.py | 12 +- app/tools/build_api_registry.py | 2 +- docs/migration/evidence/slice-01/README.md | 57 + .../evidence/slice-01/app-light-1920x1080.png | Bin 0 -> 78754 bytes docs/migration/保真迁移状态.json | 11 +- docs/migration/保真迁移账本.md | 13 +- docs/migration/目标目录与切片顺序.md | 71 + 37 files changed, 6821 insertions(+), 6327 deletions(-) create mode 100644 app/backend/application.py create mode 100644 app/backend/bootstrap/config.py create mode 100644 app/backend/bootstrap/runtime.py create mode 100644 app/backend/features/accounts/__init__.py create mode 100644 app/backend/features/accounts/http.py create mode 100644 app/backend/features/accounts/repository.py create mode 100644 app/backend/features/accounts/security.py create mode 100644 app/backend/features/accounts/service.py create mode 100644 app/backend/features/system/__init__.py create mode 100644 app/backend/features/system/http.py create mode 100644 app/backend/features/system/repository.py create mode 100644 app/backend/http/handler.py create mode 100644 app/tests/test_preservation_slice_accounts.py create mode 100644 docs/migration/evidence/slice-01/README.md create mode 100644 docs/migration/evidence/slice-01/app-light-1920x1080.png create mode 100644 docs/migration/目标目录与切片顺序.md diff --git a/app/app_config.py b/app/app_config.py index 1f93fd6..0ec4284 100644 --- a/app/app_config.py +++ b/app/app_config.py @@ -1,129 +1,3 @@ -from __future__ import annotations +"""Compatibility imports for code that still uses the original configuration module.""" -import calendar -import os -import re -from datetime import date, datetime, timedelta, timezone -from pathlib import Path -from typing import Any - - -APP_DIR = Path(__file__).resolve().parent -STATIC_DIR = APP_DIR / "static" -DATA_DIR = APP_DIR / "data" -ENV_FILE = APP_DIR / ".env" -MENTOR_SKILLS_DIR = APP_DIR / "游资skills" -PRIVATE_MENTOR_SKILLS_DIR = DATA_DIR / "private-mentor-skills" -TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,128}$") -USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_\-\u4e00-\u9fff]{3,30}$") -SESSION_COOKIE = "xiaobai_session" -SESSION_MAX_AGE = 30 * 24 * 60 * 60 - - -def load_local_env() -> None: - if not ENV_FILE.exists(): - return - for raw_line in ENV_FILE.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) - - -def save_local_env(updates: dict[str, str]) -> None: - values: dict[str, str] = {} - if ENV_FILE.exists(): - for raw_line in ENV_FILE.read_text(encoding="utf-8").splitlines(): - if "=" in raw_line and not raw_line.lstrip().startswith("#"): - key, value = raw_line.split("=", 1) - values[key.strip()] = value.strip().strip('"').strip("'") - values.update(updates) - ENV_FILE.write_text( - "".join(f"{key}={value}\n" for key, value in values.items()), - encoding="utf-8", - ) - - -def remove_local_env(keys: set[str]) -> None: - if not ENV_FILE.exists(): - return - kept = [] - for raw_line in ENV_FILE.read_text(encoding="utf-8").splitlines(): - if "=" in raw_line and not raw_line.lstrip().startswith("#"): - key = raw_line.split("=", 1)[0].strip() - if key in keys: - continue - kept.append(raw_line) - ENV_FILE.write_text("".join(f"{line}\n" for line in kept), encoding="utf-8") - for key in keys: - os.environ.pop(key, None) - - -def normalize_date(value: str) -> str: - compact = value.replace("-", "").strip() - try: - parsed = datetime.strptime(compact, "%Y%m%d") - except ValueError as exc: - raise ValueError("日期格式应为 YYYY-MM-DD。") from exc - if parsed.date() > date.today(): - raise ValueError("不能查询未来日期。") - return parsed.strftime("%Y%m%d") - - -def validate_stock_code(value: str) -> str: - code = value.strip() - if not re.fullmatch(r"\d{6}", code): - raise ValueError("股票代码应为 6 位数字。") - return code - - -def tushare_code(code: str) -> str: - if code.startswith(("4", "8", "9")): - suffix = "BJ" - elif code.startswith("6"): - suffix = "SH" - else: - suffix = "SZ" - return f"{code}.{suffix}" - - -def validate_text(value: Any, label: str, maximum: int, required: bool = False) -> str: - text = str(value or "").strip() - if required and not text: - raise ValueError(f"{label}不能为空。") - if len(text) > maximum: - raise ValueError(f"{label}不能超过 {maximum} 个字符。") - return text - - -def parse_iso_datetime(value: Any) -> datetime | None: - text = str(value or "").strip() - if not text: - return None - try: - parsed = datetime.fromisoformat(text) - except ValueError: - return None - return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed.astimezone(timezone.utc) - - -def membership_boundary(value: Any, end: bool) -> str | None: - text = str(value or "").strip() - if not text: - return None - try: - day = datetime.strptime(text, "%Y-%m-%d").replace(tzinfo=timezone.utc) - except ValueError as exc: - raise ValueError("会员日期格式应为 YYYY-MM-DD。") from exc - if end: - day += timedelta(days=1) - return day.isoformat(timespec="seconds") - - -def add_months(value: datetime, months: int) -> datetime: - month_index = value.year * 12 + value.month - 1 + months - year, zero_based_month = divmod(month_index, 12) - month = zero_based_month + 1 - day = min(value.day, calendar.monthrange(year, month)[1]) - return value.replace(year=year, month=month, day=day) +from backend.bootstrap.config import * # noqa: F401,F403 diff --git a/app/backend/application.py b/app/backend/application.py new file mode 100644 index 0000000..4295902 --- /dev/null +++ b/app/backend/application.py @@ -0,0 +1,5422 @@ +from __future__ import annotations + +import copy +import json +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.server import BaseHTTPRequestHandler +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.container import build_application_container +from backend.bootstrap.settings import load_runtime_settings +from backend.http import HttpTransportMixin +from backend.llm import LLMGateway, LLMGatewayError +from chart_data_provider import ChartDataError +from backend.bootstrap.config import ( + DATA_DIR, + MENTOR_SKILLS_DIR, + PRIVATE_MENTOR_SKILLS_DIR, + TOKEN_PATTERN, + normalize_date, + 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 backend.features.accounts.http import AccountHttpMixin +from backend.features.accounts.security import SecretVault +from backend.features.accounts.service import AccountService +from backend.features.system import SystemHttpMixin +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.accounts = AccountService( + database=self.database, + vault=self.vault, + current_user_supplier=lambda: self.current_user_id, + access_supplier=lambda: getattr(self._request_context, "access", {}), + bind_user=self.bind_user, + personal_field_builder=build_personal_field, + auth_lock=self.auth_lock, + ) + 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]: + return self.accounts.membership() + + 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: + return self._platform_usage_today_for_user(self.current_user_id) + + def _platform_usage_today_for_user(self, user_id: int) -> 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( + 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]]: + return self.accounts.admin_users(self._platform_usage_today_for_user) + + def update_membership(self, payload: dict[str, Any]) -> None: + self.accounts.update_membership(payload) + + 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]: + return self.accounts.register(username, password) + + def login_account(self, username: str, password: str) -> dict[str, Any]: + return self.accounts.login(username, password) + + def change_password(self, current_password: str, new_password: str) -> None: + self.accounts.change_password(current_password, new_password) + + def create_account_session(self, user: dict[str, Any]) -> dict[str, Any]: + return self.accounts.create_session(user) + + @staticmethod + def _validate_account_input(username: str, password: str) -> None: + AccountService.validate_input(username, password) + + def save_birth_profile(self, payload: dict[str, Any]) -> dict[str, Any]: + return self.accounts.save_birth_profile(payload) + + def stored_birth_profile(self) -> dict[str, str] | None: + return self.accounts.stored_birth_profile() + + def account_personal_field( + self, + current_date: str, + current_field: dict[str, Any], + public: bool = False, + ) -> dict[str, Any] | None: + return self.accounts.personal_field(current_date, current_field, public) + + @staticmethod + def _public_personal_profile(personal: dict[str, Any]) -> dict[str, Any]: + return AccountService.public_personal_profile(personal) + + 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"(? 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"(?= 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"(? 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( + AccountHttpMixin, + SystemHttpMixin, + HttpTransportMixin, + BaseHTTPRequestHandler, +): + server_version = "XiaobaiReviewWeb/0.8" + application_service = SERVICE + route_registry = ROUTES + + 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 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 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 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) diff --git a/app/backend/bootstrap/__init__.py b/app/backend/bootstrap/__init__.py index 097a1fe..d3a4dd3 100644 --- a/app/backend/bootstrap/__init__.py +++ b/app/backend/bootstrap/__init__.py @@ -1,9 +1,23 @@ -from .container import ApplicationContainer, build_application_container -from .settings import RuntimeSettings, load_runtime_settings - __all__ = [ "ApplicationContainer", "RuntimeSettings", "build_application_container", "load_runtime_settings", + "main", ] + + +def __getattr__(name: str): + if name in {"ApplicationContainer", "build_application_container"}: + from . import container + + return getattr(container, name) + if name in {"RuntimeSettings", "load_runtime_settings"}: + from . import settings + + return getattr(settings, name) + if name == "main": + from .runtime import main + + return main + raise AttributeError(name) diff --git a/app/backend/bootstrap/config.py b/app/backend/bootstrap/config.py new file mode 100644 index 0000000..08d4d11 --- /dev/null +++ b/app/backend/bootstrap/config.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import calendar +import os +import re +from datetime import date, datetime, timedelta, timezone +from pathlib import Path +from typing import Any + + +APP_DIR = Path(__file__).resolve().parents[2] +STATIC_DIR = APP_DIR / "static" +DATA_DIR = APP_DIR / "data" +ENV_FILE = APP_DIR / ".env" +MENTOR_SKILLS_DIR = APP_DIR / "游资skills" +PRIVATE_MENTOR_SKILLS_DIR = DATA_DIR / "private-mentor-skills" +TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,128}$") +USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_\-\u4e00-\u9fff]{3,30}$") +SESSION_COOKIE = "xiaobai_session" +SESSION_MAX_AGE = 30 * 24 * 60 * 60 + + +def load_local_env() -> None: + if not ENV_FILE.exists(): + return + for raw_line in ENV_FILE.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +def save_local_env(updates: dict[str, str]) -> None: + values: dict[str, str] = {} + if ENV_FILE.exists(): + for raw_line in ENV_FILE.read_text(encoding="utf-8").splitlines(): + if "=" in raw_line and not raw_line.lstrip().startswith("#"): + key, value = raw_line.split("=", 1) + values[key.strip()] = value.strip().strip('"').strip("'") + values.update(updates) + ENV_FILE.write_text( + "".join(f"{key}={value}\n" for key, value in values.items()), + encoding="utf-8", + ) + + +def remove_local_env(keys: set[str]) -> None: + if not ENV_FILE.exists(): + return + kept = [] + for raw_line in ENV_FILE.read_text(encoding="utf-8").splitlines(): + if "=" in raw_line and not raw_line.lstrip().startswith("#"): + key = raw_line.split("=", 1)[0].strip() + if key in keys: + continue + kept.append(raw_line) + ENV_FILE.write_text("".join(f"{line}\n" for line in kept), encoding="utf-8") + for key in keys: + os.environ.pop(key, None) + + +def normalize_date(value: str) -> str: + compact = value.replace("-", "").strip() + try: + parsed = datetime.strptime(compact, "%Y%m%d") + except ValueError as exc: + raise ValueError("日期格式应为 YYYY-MM-DD。") from exc + if parsed.date() > date.today(): + raise ValueError("不能查询未来日期。") + return parsed.strftime("%Y%m%d") + + +def validate_stock_code(value: str) -> str: + code = value.strip() + if not re.fullmatch(r"\d{6}", code): + raise ValueError("股票代码应为 6 位数字。") + return code + + +def tushare_code(code: str) -> str: + if code.startswith(("4", "8", "9")): + suffix = "BJ" + elif code.startswith("6"): + suffix = "SH" + else: + suffix = "SZ" + return f"{code}.{suffix}" + + +def validate_text(value: Any, label: str, maximum: int, required: bool = False) -> str: + text = str(value or "").strip() + if required and not text: + raise ValueError(f"{label}不能为空。") + if len(text) > maximum: + raise ValueError(f"{label}不能超过 {maximum} 个字符。") + return text + + +def parse_iso_datetime(value: Any) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed.astimezone(timezone.utc) + + +def membership_boundary(value: Any, end: bool) -> str | None: + text = str(value or "").strip() + if not text: + return None + try: + day = datetime.strptime(text, "%Y-%m-%d").replace(tzinfo=timezone.utc) + except ValueError as exc: + raise ValueError("会员日期格式应为 YYYY-MM-DD。") from exc + if end: + day += timedelta(days=1) + return day.isoformat(timespec="seconds") + + +def add_months(value: datetime, months: int) -> datetime: + month_index = value.year * 12 + value.month - 1 + months + year, zero_based_month = divmod(month_index, 12) + month = zero_based_month + 1 + day = min(value.day, calendar.monthrange(year, month)[1]) + return value.replace(year=year, month=month, day=day) diff --git a/app/backend/bootstrap/runtime.py b/app/backend/bootstrap/runtime.py new file mode 100644 index 0000000..f45d2b3 --- /dev/null +++ b/app/backend/bootstrap/runtime.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import argparse +from http.server import ThreadingHTTPServer +from typing import Any + + +def main(handler_class: type[Any] | None = None, service: Any | None = None) -> None: + if handler_class is None or service is None: + from backend.application import RequestHandler, SERVICE + + handler_class = handler_class or RequestHandler + service = service or SERVICE + 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), handler_class) + 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() diff --git a/app/backend/bootstrap/settings.py b/app/backend/bootstrap/settings.py index e6a9a23..37db457 100644 --- a/app/backend/bootstrap/settings.py +++ b/app/backend/bootstrap/settings.py @@ -4,8 +4,8 @@ import os from dataclasses import dataclass from typing import Mapping -from app_config import load_local_env, save_local_env -from security import SecretVault +from backend.bootstrap.config import load_local_env, save_local_env +from backend.features.accounts.security import SecretVault def environment_credentials(environment: Mapping[str, str]) -> dict[str, str]: diff --git a/app/backend/data/policy.py b/app/backend/data/policy.py index 853f123..fc05c73 100644 --- a/app/backend/data/policy.py +++ b/app/backend/data/policy.py @@ -3,7 +3,7 @@ from __future__ import annotations import json from pathlib import Path -from app_config import APP_DIR +from backend.bootstrap.config import APP_DIR from backend.data.contracts import DataUsage, DatasetContract, ProviderContract diff --git a/app/backend/data/quality.py b/app/backend/data/quality.py index c35d96e..be38b4c 100644 --- a/app/backend/data/quality.py +++ b/app/backend/data/quality.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -from app_config import APP_DIR +from backend.bootstrap.config import APP_DIR from backend.data.contracts import DataUsage from backend.data.policy import DataPolicyError, DataSourcePolicy diff --git a/app/backend/features/accounts/__init__.py b/app/backend/features/accounts/__init__.py new file mode 100644 index 0000000..393cae9 --- /dev/null +++ b/app/backend/features/accounts/__init__.py @@ -0,0 +1,24 @@ +__all__ = [ + "AccountHttpMixin", + "AccountService", + "SecretVault", + "hash_password", + "token_hash", + "verify_password", +] + + +def __getattr__(name: str): + if name == "AccountHttpMixin": + from .http import AccountHttpMixin + + return AccountHttpMixin + if name == "AccountService": + from .service import AccountService + + return AccountService + if name in {"SecretVault", "hash_password", "token_hash", "verify_password"}: + from . import security + + return getattr(security, name) + raise AttributeError(name) diff --git a/app/backend/features/accounts/http.py b/app/backend/features/accounts/http.py new file mode 100644 index 0000000..4bc282e --- /dev/null +++ b/app/backend/features/accounts/http.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import json +from http import HTTPStatus + + +class AccountHttpMixin: + def auth_register(self) -> None: + try: + body = self.read_json_body() + result = self.application_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 = self.application_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: + service = self.application_service + 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: + from backend.features.accounts.security import token_hash + + self.application_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 = self.application_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("两次输入的新密码不一致。") + self.application_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_membership(self) -> None: + try: + service = self.application_service + 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) diff --git a/app/backend/features/accounts/repository.py b/app/backend/features/accounts/repository.py new file mode 100644 index 0000000..98cc534 --- /dev/null +++ b/app/backend/features/accounts/repository.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import sqlite3 +from datetime import datetime, timezone +from typing import Any + + +class AccountRepositoryMixin: + """Original SQLite account persistence methods, moved without query changes.""" + + def count_users(self) -> int: + with self.connect() as connection: + row = connection.execute("SELECT COUNT(*) AS total FROM users").fetchone() + return int(row["total"] if row else 0) + + def first_user_id(self) -> int: + with self.connect() as connection: + row = connection.execute("SELECT MIN(id) AS id FROM users").fetchone() + return int(row["id"] or 0) if row else 0 + + def create_user( + self, + username: str, + password_salt: str, + password_hash: str, + ) -> dict[str, Any]: + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + try: + with self.connect() as connection: + role = "admin" if int(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]) == 0 else "user" + cursor = connection.execute( + """ + INSERT INTO users + (username, password_salt, password_hash, role, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + (username, password_salt, password_hash, role, now, now), + ) + user_id = int(cursor.lastrowid) + except sqlite3.IntegrityError as exc: + raise ValueError("该账号名已被使用。") from exc + return {"id": user_id, "username": username, "role": role, "created_at": now} + + def user_by_username(self, username: str) -> dict[str, Any] | None: + with self.connect() as connection: + row = connection.execute( + """ + SELECT id, username, password_salt, password_hash, role, llm_mode, + membership_status, membership_plan, membership_starts_at, + membership_expires_at, created_at + FROM users WHERE username = ? COLLATE NOCASE + """, + (username,), + ).fetchone() + return dict(row) if row else None + + def user_password(self, user_id: int) -> dict[str, str] | None: + with self.connect() as connection: + row = connection.execute( + "SELECT password_salt, password_hash FROM users WHERE id = ?", + (user_id,), + ).fetchone() + return dict(row) if row else None + + def update_user_password(self, user_id: int, password_salt: str, password_hash: str) -> bool: + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + with self.connect() as connection: + cursor = connection.execute( + "UPDATE users SET password_salt = ?, password_hash = ?, updated_at = ? WHERE id = ?", + (password_salt, password_hash, now, user_id), + ) + return cursor.rowcount > 0 + + def delete_user(self, user_id: int) -> bool: + with self.connect() as connection: + cursor = connection.execute("DELETE FROM users WHERE id = ?", (user_id,)) + return cursor.rowcount > 0 + + def create_session( + self, + session_hash: str, + user_id: int, + csrf_token: str, + expires_at: str, + ) -> None: + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + with self.connect() as connection: + connection.execute("DELETE FROM user_sessions WHERE expires_at <= ?", (now,)) + connection.execute( + """ + INSERT INTO user_sessions + (token_hash, user_id, csrf_token, expires_at, created_at, last_seen_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + (session_hash, user_id, csrf_token, expires_at, now, now), + ) + + def session_user(self, session_hash: str) -> dict[str, Any] | None: + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + with self.connect() as connection: + row = connection.execute( + """ + SELECT u.id, u.username, u.role, u.llm_mode, u.membership_status, + u.membership_plan, u.membership_starts_at, u.membership_expires_at, + u.created_at, s.csrf_token, s.expires_at + FROM user_sessions AS s + JOIN users AS u ON u.id = s.user_id + WHERE s.token_hash = ? AND s.expires_at > ? + """, + (session_hash, now), + ).fetchone() + if row: + connection.execute( + "UPDATE user_sessions SET last_seen_at = ? WHERE token_hash = ?", + (now, session_hash), + ) + return dict(row) if row else None + + def delete_session(self, session_hash: str) -> bool: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM user_sessions WHERE token_hash = ?", + (session_hash,), + ) + return cursor.rowcount > 0 + + def get_user_credentials(self, user_id: int) -> str: + with self.connect() as connection: + row = connection.execute( + "SELECT encrypted_payload FROM user_credentials WHERE user_id = ?", + (user_id,), + ).fetchone() + return str(row["encrypted_payload"]) if row else "" + + def save_user_credentials(self, user_id: int, encrypted_payload: str) -> None: + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + with self.connect() as connection: + connection.execute( + """ + INSERT INTO user_credentials (user_id, encrypted_payload, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + encrypted_payload = excluded.encrypted_payload, + updated_at = excluded.updated_at + """, + (user_id, encrypted_payload, now), + ) + + def list_user_credentials(self) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute( + "SELECT user_id, encrypted_payload FROM user_credentials ORDER BY user_id" + ).fetchall() + return [dict(row) for row in rows] + + def user_access(self, user_id: int) -> dict[str, Any] | None: + with self.connect() as connection: + row = connection.execute( + """ + SELECT id, username, role, llm_mode, membership_status, membership_plan, + membership_starts_at, membership_expires_at, created_at + FROM users WHERE id = ? + """, + (user_id,), + ).fetchone() + return dict(row) if row else None + + def list_users(self) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute( + """ + SELECT id, username, role, llm_mode, membership_status, membership_plan, + membership_starts_at, membership_expires_at, created_at + FROM users ORDER BY id + """ + ).fetchall() + return [dict(row) for row in rows] + + def update_user_llm_mode(self, user_id: int, mode: str) -> None: + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + with self.connect() as connection: + connection.execute( + "UPDATE users SET llm_mode = ?, updated_at = ? WHERE id = ?", + (mode, now, user_id), + ) + + def update_membership( + self, + user_id: int, + status: str, + plan: str, + starts_at: str | None, + expires_at: str | None, + ) -> bool: + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + with self.connect() as connection: + cursor = connection.execute( + """ + UPDATE users + SET membership_status = ?, membership_plan = ?, + membership_starts_at = ?, membership_expires_at = ?, updated_at = ? + WHERE id = ? + """, + (status, plan, starts_at, expires_at, now, user_id), + ) + return cursor.rowcount > 0 + + def get_user_birth_profile(self, user_id: int) -> str: + with self.connect() as connection: + row = connection.execute( + "SELECT encrypted_payload FROM user_birth_profiles WHERE user_id = ?", + (user_id,), + ).fetchone() + return str(row["encrypted_payload"]) if row else "" + + def save_user_birth_profile(self, user_id: int, encrypted_payload: str) -> None: + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + with self.connect() as connection: + connection.execute( + """ + INSERT INTO user_birth_profiles (user_id, encrypted_payload, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + encrypted_payload = excluded.encrypted_payload, + updated_at = excluded.updated_at + """, + (user_id, encrypted_payload, now), + ) + + def delete_user_birth_profile(self, user_id: int) -> bool: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM user_birth_profiles WHERE user_id = ?", + (user_id,), + ) + return cursor.rowcount > 0 diff --git a/app/backend/features/accounts/security.py b/app/backend/features/accounts/security.py new file mode 100644 index 0000000..04a0ff7 --- /dev/null +++ b/app/backend/features/accounts/security.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +from typing import Any + +from cryptography.fernet import Fernet, InvalidToken + + +PASSWORD_SCRYPT_N = 2**14 +PASSWORD_SCRYPT_R = 8 +PASSWORD_SCRYPT_P = 1 + + +class SecretVault: + def __init__(self, key: str) -> None: + try: + self._fernet = Fernet(key.encode("ascii")) + except (ValueError, TypeError) as exc: + raise ValueError("APP_ENCRYPTION_KEY 格式无效。") from exc + + @staticmethod + def generate_key() -> str: + return Fernet.generate_key().decode("ascii") + + def encrypt_json(self, payload: dict[str, Any]) -> str: + raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + return self._fernet.encrypt(raw).decode("ascii") + + def decrypt_json(self, token: str) -> dict[str, Any]: + if not token: + return {} + try: + payload = json.loads(self._fernet.decrypt(token.encode("ascii")).decode("utf-8")) + except (InvalidToken, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError("账号加密数据无法解密,请检查 APP_ENCRYPTION_KEY。") from exc + if not isinstance(payload, dict): + raise ValueError("账号加密数据格式无效。") + return payload + + +def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]: + raw_salt = salt or os.urandom(16) + digest = hashlib.scrypt( + password.encode("utf-8"), + salt=raw_salt, + n=PASSWORD_SCRYPT_N, + r=PASSWORD_SCRYPT_R, + p=PASSWORD_SCRYPT_P, + dklen=32, + ) + return ( + base64.urlsafe_b64encode(raw_salt).decode("ascii"), + base64.urlsafe_b64encode(digest).decode("ascii"), + ) + + +def verify_password(password: str, salt_text: str, expected_hash: str) -> bool: + try: + salt = base64.urlsafe_b64decode(salt_text.encode("ascii")) + _, actual_hash = hash_password(password, salt) + except (ValueError, TypeError): + return False + return hmac.compare_digest(actual_hash, expected_hash) + + +def token_hash(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() diff --git a/app/backend/features/accounts/service.py b/app/backend/features/accounts/service.py new file mode 100644 index 0000000..aa6958c --- /dev/null +++ b/app/backend/features/accounts/service.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import secrets +import threading +from collections.abc import Callable +from datetime import date, datetime, timedelta, timezone +from typing import Any + +from backend.bootstrap.config import ( + SESSION_MAX_AGE, + USERNAME_PATTERN, + add_months, + normalize_date, + parse_iso_datetime, +) +from backend.features.accounts.security import ( + SecretVault, + hash_password, + token_hash, + verify_password, +) + + +class AccountService: + """Preserved account, session, membership and birth-profile behavior.""" + + def __init__( + self, + database: Any, + vault: SecretVault, + current_user_supplier: Callable[[], int], + access_supplier: Callable[[], dict[str, Any]], + bind_user: Callable[[int], None], + personal_field_builder: Callable[..., dict[str, Any]], + auth_lock: threading.Lock, + ) -> None: + self.database = database + self.vault = vault + self.current_user_supplier = current_user_supplier + self.access_supplier = access_supplier + self.bind_user = bind_user + self.personal_field_builder = personal_field_builder + self.auth_lock = auth_lock + + @property + def current_user_id(self) -> int: + return int(self.current_user_supplier()) + + @staticmethod + def membership_for_access(access: dict[str, Any]) -> dict[str, Any]: + 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 membership(self) -> dict[str, Any]: + access = self.access_supplier() or self.database.user_access(self.current_user_id) or {} + return self.membership_for_access(access) + + def register(self, username: str, password: str) -> dict[str, Any]: + username = username.strip() + self.validate_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_session(user) + + def login(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_session(user) + + def change_password(self, current_password: str, new_password: str) -> None: + current_password = str(current_password or "") + access = self.database.user_access(self.current_user_id) + self.validate_input(str(access["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_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_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 = self.personal_field_builder(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 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 = self.personal_field_builder( + 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 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 admin_users( + self, usage_supplier: Callable[[int], int] + ) -> list[dict[str, Any]]: + rows = [] + for user in self.database.list_users(): + membership = self.membership_for_access(user) + used = usage_supplier(int(user["id"])) if membership["active"] else 0 + rows.append({ + **user, + "membership_active": membership["active"], + "membership_subscribed": membership["subscribed"], + "used_today": used, + }) + return rows diff --git a/app/backend/features/alerts/service.py b/app/backend/features/alerts/service.py index bd81af4..7021be1 100644 --- a/app/backend/features/alerts/service.py +++ b/app/backend/features/alerts/service.py @@ -4,7 +4,7 @@ import secrets from datetime import date, datetime from typing import Any -from app_config import validate_text +from backend.bootstrap.config import validate_text from backend.database.repositories import AlertRepository diff --git a/app/backend/features/review/trade_journal.py b/app/backend/features/review/trade_journal.py index 30fb54e..f622303 100644 --- a/app/backend/features/review/trade_journal.py +++ b/app/backend/features/review/trade_journal.py @@ -4,7 +4,7 @@ import json from datetime import date from typing import Any -from app_config import normalize_date, validate_stock_code, validate_text +from backend.bootstrap.config import normalize_date, validate_stock_code, validate_text from backend.database.repositories import TradeJournalRepository diff --git a/app/backend/features/system/__init__.py b/app/backend/features/system/__init__.py new file mode 100644 index 0000000..519d71e --- /dev/null +++ b/app/backend/features/system/__init__.py @@ -0,0 +1,3 @@ +from .http import SystemHttpMixin + +__all__ = ["SystemHttpMixin"] diff --git a/app/backend/features/system/http.py b/app/backend/features/system/http.py new file mode 100644 index 0000000..531cceb --- /dev/null +++ b/app/backend/features/system/http.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import json +from datetime import date +from http import HTTPStatus + + +class SystemHttpMixin: + def save_system_settings(self) -> None: + try: + result = self.application_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 = self.application_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 start_background_refresh(self) -> None: + try: + body = self.read_json_body(allow_empty=True) + started = self.application_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) diff --git a/app/backend/features/system/repository.py b/app/backend/features/system/repository.py new file mode 100644 index 0000000..dda54ef --- /dev/null +++ b/app/backend/features/system/repository.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from datetime import datetime, timezone + + +class SystemSettingsRepositoryMixin: + """Original encrypted system-setting persistence methods.""" + + def get_system_setting(self, key: str) -> str: + with self.connect() as connection: + row = connection.execute( + "SELECT encrypted_payload FROM system_settings WHERE setting_key = ?", + (key,), + ).fetchone() + return str(row["encrypted_payload"]) if row else "" + + def save_system_setting(self, key: str, encrypted_payload: str) -> None: + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + with self.connect() as connection: + connection.execute( + """ + INSERT INTO system_settings (setting_key, encrypted_payload, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(setting_key) DO UPDATE SET + encrypted_payload = excluded.encrypted_payload, + updated_at = excluded.updated_at + """, + (key, encrypted_payload, now), + ) diff --git a/app/backend/http/__init__.py b/app/backend/http/__init__.py index 61b4b88..83888b7 100644 --- a/app/backend/http/__init__.py +++ b/app/backend/http/__init__.py @@ -1,8 +1,9 @@ from .context import correlation_id from .errors import normalize_error_payload +from .handler import HttpTransportMixin from .router import AccessRole, ApiRoute, ApiRouteRegistry, RouteRegistryError __all__ = [ "AccessRole", "ApiRoute", "ApiRouteRegistry", "RouteRegistryError", - "correlation_id", "normalize_error_payload", + "HttpTransportMixin", "correlation_id", "normalize_error_payload", ] diff --git a/app/backend/http/handler.py b/app/backend/http/handler.py new file mode 100644 index 0000000..186fb7e --- /dev/null +++ b/app/backend/http/handler.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import json +import mimetypes +import secrets +from http import HTTPStatus +from http.cookies import SimpleCookie +from typing import Any +from urllib.parse import unquote + +from backend.bootstrap.config import SESSION_COOKIE, SESSION_MAX_AGE, STATIC_DIR +from backend.features.accounts.security import token_hash +from backend.http.context import correlation_id +from backend.http.errors import normalize_error_payload + + +class HttpTransportMixin: + """Original HTTP transport, static-file, session and access behavior.""" + + application_service: Any + route_registry: Any + + 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() + service = self.application_service + 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 self.application_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 = self.route_registry.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 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}") diff --git a/app/backend/http/router.py b/app/backend/http/router.py index 337edf2..afaa946 100644 --- a/app/backend/http/router.py +++ b/app/backend/http/router.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Literal, cast -from app_config import APP_DIR +from backend.bootstrap.config import APP_DIR AccessRole = Literal["public", "authenticated", "member", "admin"] diff --git a/app/backend/jobs/registry.py b/app/backend/jobs/registry.py index 8a5f662..d3e2738 100644 --- a/app/backend/jobs/registry.py +++ b/app/backend/jobs/registry.py @@ -4,7 +4,7 @@ import json from dataclasses import dataclass from pathlib import Path -from app_config import APP_DIR +from backend.bootstrap.config import APP_DIR @dataclass(frozen=True) diff --git a/app/database.py b/app/database.py index 411276e..9f23746 100644 --- a/app/database.py +++ b/app/database.py @@ -7,6 +7,8 @@ from pathlib import Path from typing import Any from backend.database import MIGRATIONS, MigrationRunner, SQLiteConnectionFactory +from backend.features.accounts.repository import AccountRepositoryMixin +from backend.features.system.repository import SystemSettingsRepositoryMixin def _optional_float(value: Any) -> float | None: @@ -18,7 +20,7 @@ def _optional_float(value: Any) -> float | None: return None -class ReviewDatabase: +class ReviewDatabase(AccountRepositoryMixin, SystemSettingsRepositoryMixin): def __init__(self, path: Path) -> None: self.path = path self.path.parent.mkdir(parents=True, exist_ok=True) @@ -647,225 +649,6 @@ class ReviewDatabase: ) MigrationRunner().apply(connection, MIGRATIONS) - def count_users(self) -> int: - with self.connect() as connection: - row = connection.execute("SELECT COUNT(*) AS total FROM users").fetchone() - return int(row["total"] if row else 0) - - def first_user_id(self) -> int: - with self.connect() as connection: - row = connection.execute("SELECT MIN(id) AS id FROM users").fetchone() - return int(row["id"] or 0) if row else 0 - - def create_user( - self, - username: str, - password_salt: str, - password_hash: str, - ) -> dict[str, Any]: - now = datetime.now(timezone.utc).isoformat(timespec="seconds") - try: - with self.connect() as connection: - role = "admin" if int(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]) == 0 else "user" - cursor = connection.execute( - """ - INSERT INTO users - (username, password_salt, password_hash, role, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - """, - (username, password_salt, password_hash, role, now, now), - ) - user_id = int(cursor.lastrowid) - except sqlite3.IntegrityError as exc: - raise ValueError("该账号名已被使用。") from exc - return {"id": user_id, "username": username, "role": role, "created_at": now} - - def user_by_username(self, username: str) -> dict[str, Any] | None: - with self.connect() as connection: - row = connection.execute( - """ - SELECT id, username, password_salt, password_hash, role, llm_mode, - membership_status, membership_plan, membership_starts_at, - membership_expires_at, created_at - FROM users WHERE username = ? COLLATE NOCASE - """, - (username,), - ).fetchone() - return dict(row) if row else None - - def user_password(self, user_id: int) -> dict[str, str] | None: - with self.connect() as connection: - row = connection.execute( - "SELECT password_salt, password_hash FROM users WHERE id = ?", - (user_id,), - ).fetchone() - return dict(row) if row else None - - def update_user_password(self, user_id: int, password_salt: str, password_hash: str) -> bool: - now = datetime.now(timezone.utc).isoformat(timespec="seconds") - with self.connect() as connection: - cursor = connection.execute( - "UPDATE users SET password_salt = ?, password_hash = ?, updated_at = ? WHERE id = ?", - (password_salt, password_hash, now, user_id), - ) - return cursor.rowcount > 0 - - def delete_user(self, user_id: int) -> bool: - with self.connect() as connection: - cursor = connection.execute("DELETE FROM users WHERE id = ?", (user_id,)) - return cursor.rowcount > 0 - - def create_session( - self, - session_hash: str, - user_id: int, - csrf_token: str, - expires_at: str, - ) -> None: - now = datetime.now(timezone.utc).isoformat(timespec="seconds") - with self.connect() as connection: - connection.execute("DELETE FROM user_sessions WHERE expires_at <= ?", (now,)) - connection.execute( - """ - INSERT INTO user_sessions - (token_hash, user_id, csrf_token, expires_at, created_at, last_seen_at) - VALUES (?, ?, ?, ?, ?, ?) - """, - (session_hash, user_id, csrf_token, expires_at, now, now), - ) - - def session_user(self, session_hash: str) -> dict[str, Any] | None: - now = datetime.now(timezone.utc).isoformat(timespec="seconds") - with self.connect() as connection: - row = connection.execute( - """ - SELECT u.id, u.username, u.role, u.llm_mode, u.membership_status, - u.membership_plan, u.membership_starts_at, u.membership_expires_at, - u.created_at, s.csrf_token, s.expires_at - FROM user_sessions AS s - JOIN users AS u ON u.id = s.user_id - WHERE s.token_hash = ? AND s.expires_at > ? - """, - (session_hash, now), - ).fetchone() - if row: - connection.execute( - "UPDATE user_sessions SET last_seen_at = ? WHERE token_hash = ?", - (now, session_hash), - ) - return dict(row) if row else None - - def delete_session(self, session_hash: str) -> bool: - with self.connect() as connection: - cursor = connection.execute( - "DELETE FROM user_sessions WHERE token_hash = ?", - (session_hash,), - ) - return cursor.rowcount > 0 - - def get_user_credentials(self, user_id: int) -> str: - with self.connect() as connection: - row = connection.execute( - "SELECT encrypted_payload FROM user_credentials WHERE user_id = ?", - (user_id,), - ).fetchone() - return str(row["encrypted_payload"]) if row else "" - - def save_user_credentials(self, user_id: int, encrypted_payload: str) -> None: - now = datetime.now(timezone.utc).isoformat(timespec="seconds") - with self.connect() as connection: - connection.execute( - """ - INSERT INTO user_credentials (user_id, encrypted_payload, updated_at) - VALUES (?, ?, ?) - ON CONFLICT(user_id) DO UPDATE SET - encrypted_payload = excluded.encrypted_payload, - updated_at = excluded.updated_at - """, - (user_id, encrypted_payload, now), - ) - - def list_user_credentials(self) -> list[dict[str, Any]]: - with self.connect() as connection: - rows = connection.execute( - "SELECT user_id, encrypted_payload FROM user_credentials ORDER BY user_id" - ).fetchall() - return [dict(row) for row in rows] - - def get_system_setting(self, key: str) -> str: - with self.connect() as connection: - row = connection.execute( - "SELECT encrypted_payload FROM system_settings WHERE setting_key = ?", - (key,), - ).fetchone() - return str(row["encrypted_payload"]) if row else "" - - def save_system_setting(self, key: str, encrypted_payload: str) -> None: - now = datetime.now(timezone.utc).isoformat(timespec="seconds") - with self.connect() as connection: - connection.execute( - """ - INSERT INTO system_settings (setting_key, encrypted_payload, updated_at) - VALUES (?, ?, ?) - ON CONFLICT(setting_key) DO UPDATE SET - encrypted_payload = excluded.encrypted_payload, - updated_at = excluded.updated_at - """, - (key, encrypted_payload, now), - ) - - def user_access(self, user_id: int) -> dict[str, Any] | None: - with self.connect() as connection: - row = connection.execute( - """ - SELECT id, username, role, llm_mode, membership_status, membership_plan, - membership_starts_at, membership_expires_at, created_at - FROM users WHERE id = ? - """, - (user_id,), - ).fetchone() - return dict(row) if row else None - - def list_users(self) -> list[dict[str, Any]]: - with self.connect() as connection: - rows = connection.execute( - """ - SELECT id, username, role, llm_mode, membership_status, membership_plan, - membership_starts_at, membership_expires_at, created_at - FROM users ORDER BY id - """ - ).fetchall() - return [dict(row) for row in rows] - - def update_user_llm_mode(self, user_id: int, mode: str) -> None: - now = datetime.now(timezone.utc).isoformat(timespec="seconds") - with self.connect() as connection: - connection.execute( - "UPDATE users SET llm_mode = ?, updated_at = ? WHERE id = ?", - (mode, now, user_id), - ) - - def update_membership( - self, - user_id: int, - status: str, - plan: str, - starts_at: str | None, - expires_at: str | None, - ) -> bool: - now = datetime.now(timezone.utc).isoformat(timespec="seconds") - with self.connect() as connection: - cursor = connection.execute( - """ - UPDATE users - SET membership_status = ?, membership_plan = ?, - membership_starts_at = ?, membership_expires_at = ?, updated_at = ? - WHERE id = ? - """, - (status, plan, starts_at, expires_at, now, user_id), - ) - return cursor.rowcount > 0 - def record_llm_usage( self, user_id: int, @@ -907,36 +690,6 @@ class ReviewDatabase: ).fetchone() return int(row["total"] if row else 0) - def get_user_birth_profile(self, user_id: int) -> str: - with self.connect() as connection: - row = connection.execute( - "SELECT encrypted_payload FROM user_birth_profiles WHERE user_id = ?", - (user_id,), - ).fetchone() - return str(row["encrypted_payload"]) if row else "" - - def save_user_birth_profile(self, user_id: int, encrypted_payload: str) -> None: - now = datetime.now(timezone.utc).isoformat(timespec="seconds") - with self.connect() as connection: - connection.execute( - """ - INSERT INTO user_birth_profiles (user_id, encrypted_payload, updated_at) - VALUES (?, ?, ?) - ON CONFLICT(user_id) DO UPDATE SET - encrypted_payload = excluded.encrypted_payload, - updated_at = excluded.updated_at - """, - (user_id, encrypted_payload, now), - ) - - def delete_user_birth_profile(self, user_id: int) -> bool: - with self.connect() as connection: - cursor = connection.execute( - "DELETE FROM user_birth_profiles WHERE user_id = ?", - (user_id,), - ) - return cursor.rowcount > 0 - def get_snapshot(self, trade_date: str) -> dict[str, Any] | None: with self.connect() as connection: row = connection.execute( diff --git a/app/security.py b/app/security.py index 04a0ff7..22f103b 100644 --- a/app/security.py +++ b/app/security.py @@ -1,71 +1,3 @@ -from __future__ import annotations +"""Compatibility imports for the preserved account security API.""" -import base64 -import hashlib -import hmac -import json -import os -from typing import Any - -from cryptography.fernet import Fernet, InvalidToken - - -PASSWORD_SCRYPT_N = 2**14 -PASSWORD_SCRYPT_R = 8 -PASSWORD_SCRYPT_P = 1 - - -class SecretVault: - def __init__(self, key: str) -> None: - try: - self._fernet = Fernet(key.encode("ascii")) - except (ValueError, TypeError) as exc: - raise ValueError("APP_ENCRYPTION_KEY 格式无效。") from exc - - @staticmethod - def generate_key() -> str: - return Fernet.generate_key().decode("ascii") - - def encrypt_json(self, payload: dict[str, Any]) -> str: - raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") - return self._fernet.encrypt(raw).decode("ascii") - - def decrypt_json(self, token: str) -> dict[str, Any]: - if not token: - return {} - try: - payload = json.loads(self._fernet.decrypt(token.encode("ascii")).decode("utf-8")) - except (InvalidToken, UnicodeDecodeError, json.JSONDecodeError) as exc: - raise ValueError("账号加密数据无法解密,请检查 APP_ENCRYPTION_KEY。") from exc - if not isinstance(payload, dict): - raise ValueError("账号加密数据格式无效。") - return payload - - -def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]: - raw_salt = salt or os.urandom(16) - digest = hashlib.scrypt( - password.encode("utf-8"), - salt=raw_salt, - n=PASSWORD_SCRYPT_N, - r=PASSWORD_SCRYPT_R, - p=PASSWORD_SCRYPT_P, - dklen=32, - ) - return ( - base64.urlsafe_b64encode(raw_salt).decode("ascii"), - base64.urlsafe_b64encode(digest).decode("ascii"), - ) - - -def verify_password(password: str, salt_text: str, expected_hash: str) -> bool: - try: - salt = base64.urlsafe_b64decode(salt_text.encode("ascii")) - _, actual_hash = hash_password(password, salt) - except (ValueError, TypeError): - return False - return hmac.compare_digest(actual_hash, expected_hash) - - -def token_hash(token: str) -> str: - return hashlib.sha256(token.encode("utf-8")).hexdigest() +from backend.features.accounts.security import * # noqa: F401,F403 diff --git a/app/server.py b/app/server.py index 6654a40..ec6075f 100644 --- a/app/server.py +++ b/app/server.py @@ -1,5856 +1,24 @@ -from __future__ import annotations +"""Compatibility entry point for the preserved application runtime. -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 +The implementation lives under ``backend``; this module keeps the original command and +import surface stable while migration proceeds feature by feature. +""" -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 backend.application import ( + DashboardService, + RequestHandler, + SERVICE, + automatic_screener_jobs, ) -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"(? 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"(?= 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"(? 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() +from backend.bootstrap.runtime import main + +__all__ = [ + "DashboardService", + "RequestHandler", + "SERVICE", + "automatic_screener_jobs", + "main", +] if __name__ == "__main__": diff --git a/app/tests/test_account_access.py b/app/tests/test_account_access.py index dfd8d6f..9b94ae1 100644 --- a/app/tests/test_account_access.py +++ b/app/tests/test_account_access.py @@ -6,7 +6,7 @@ import sqlite3 from pathlib import Path from database import ReviewDatabase -from security import hash_password, verify_password +from backend.features.accounts.security import hash_password, verify_password class AccountAccessTests(unittest.TestCase): diff --git a/app/tests/test_data_gateway.py b/app/tests/test_data_gateway.py index 259131c..a3058f4 100644 --- a/app/tests/test_data_gateway.py +++ b/app/tests/test_data_gateway.py @@ -47,7 +47,9 @@ class DataGatewayTests(unittest.TestCase): def test_server_has_no_direct_runtime_tushare_construction(self) -> None: from pathlib import Path - source = (Path(__file__).resolve().parents[1] / "server.py").read_text(encoding="utf-8") + source = ( + Path(__file__).resolve().parents[1] / "backend" / "application.py" + ).read_text(encoding="utf-8") self.assertEqual(source.count("TushareClient(self.token)"), 1) self.assertIn("return gateway.tushare()", source) diff --git a/app/tests/test_market_mode.py b/app/tests/test_market_mode.py index b45269e..8e6af49 100644 --- a/app/tests/test_market_mode.py +++ b/app/tests/test_market_mode.py @@ -9,7 +9,7 @@ from tushare_client import _sector_coverage_issue def load_method(name: str): - source = Path("server.py").read_text(encoding="utf-8") + source = Path("backend/application.py").read_text(encoding="utf-8") tree = ast.parse(source) dashboard_service = next( node for node in tree.body diff --git a/app/tests/test_mentor_registry.py b/app/tests/test_mentor_registry.py index 48869ac..a65ea5c 100644 --- a/app/tests/test_mentor_registry.py +++ b/app/tests/test_mentor_registry.py @@ -81,7 +81,7 @@ class MentorSkillRegistryTests(unittest.TestCase): self.assertTrue(all(item.quality_total == 6 for item in skills)) def test_server_applies_private_guard_to_every_mentor_entry_point(self): - source = (ROOT / "server.py").read_text(encoding="utf-8") + source = (ROOT / "backend" / "application.py").read_text(encoding="utf-8") mentor_section = source[source.index(" def mentor_setup"):source.index(" def _heaven_manual_schema")] self.assertGreaterEqual( mentor_section.count('include_private=self.membership()["is_admin"]'), diff --git a/app/tests/test_preservation_slice_accounts.py b/app/tests/test_preservation_slice_accounts.py new file mode 100644 index 0000000..d6c644b --- /dev/null +++ b/app/tests/test_preservation_slice_accounts.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import tempfile +import threading +import unittest +from pathlib import Path + +import server +from backend.application import DashboardService, RequestHandler +from backend.bootstrap.config import APP_DIR, STATIC_DIR +from backend.features.accounts.repository import AccountRepositoryMixin +from backend.features.accounts.security import SecretVault, token_hash +from backend.features.accounts.service import AccountService +from backend.http.handler import HttpTransportMixin +from database import ReviewDatabase + + +class AccountSliceStructureTests(unittest.TestCase): + def test_original_entrypoint_exports_canonical_runtime(self) -> None: + self.assertIs(server.DashboardService, DashboardService) + self.assertIs(server.RequestHandler, RequestHandler) + self.assertIs(server.SERVICE, RequestHandler.application_service) + + def test_runtime_paths_still_point_at_app_root(self) -> None: + self.assertEqual(APP_DIR, Path(__file__).resolve().parents[1]) + self.assertEqual(STATIC_DIR, APP_DIR / "static") + + def test_account_persistence_and_http_transport_have_single_owners(self) -> None: + for method in ( + "create_user", + "session_user", + "update_membership", + "save_user_birth_profile", + ): + self.assertNotIn(method, ReviewDatabase.__dict__) + self.assertIn(method, AccountRepositoryMixin.__dict__) + for method in ( + "require_auth", + "require_csrf", + "require_access", + "serve_static", + "send_json", + ): + self.assertNotIn(method, RequestHandler.__dict__) + self.assertIn(method, HttpTransportMixin.__dict__) + + +class AccountSliceBehaviorTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.database = ReviewDatabase(Path(self.temporary.name) / "review.db") + self.vault = SecretVault(SecretVault.generate_key()) + self.context: dict[str, object] = {"user_id": 0, "access": {}} + + def bind_user(user_id: int) -> None: + self.context["user_id"] = user_id + self.context["access"] = self.database.user_access(user_id) or {} + + self.service = AccountService( + database=self.database, + vault=self.vault, + current_user_supplier=lambda: int(self.context["user_id"]), + access_supplier=lambda: dict(self.context["access"]), + bind_user=bind_user, + personal_field_builder=lambda *args: { + "birth": "private", + "day_master": "甲木", + "current": {"trade_date": args[2]}, + "notice": "test", + }, + auth_lock=threading.Lock(), + ) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_register_login_session_and_password_contract(self) -> None: + registered = self.service.register("owner_01", "Password123") + self.assertEqual(registered["user"]["role"], "admin") + self.assertTrue(registered["user"]["membership"]["active"]) + self.assertIsNotNone( + self.database.session_user(token_hash(registered["session_token"])) + ) + + with self.assertRaisesRegex(ValueError, "账号名或密码不正确"): + self.service.login("owner_01", "wrong-password") + + self.service.change_password("Password123", "NewPassword456") + logged_in = self.service.login("owner_01", "NewPassword456") + self.assertEqual(logged_in["user"]["id"], registered["user"]["id"]) + + def test_membership_and_birth_profile_remain_account_scoped(self) -> None: + owner = self.service.register("owner_02", "Password123") + other = self.database.create_user("other_02", "salt", "hash") + self.service.update_membership( + {"user_id": other["id"], "status": "active", "duration": "3_months"} + ) + other_access = self.database.user_access(other["id"]) + self.assertEqual(other_access["membership_plan"], "3个月") + self.assertTrue(AccountService.membership_for_access(other_access)["subscribed"]) + + personal = self.service.save_birth_profile( + { + "birth_datetime": "1990-01-01 08:30", + "gender": "male", + "trade_date": "2026-07-30", + } + ) + self.assertNotIn("birth", personal) + self.assertEqual(personal["day_master"], "甲木") + self.assertTrue(self.database.get_user_birth_profile(owner["user"]["id"])) + self.assertEqual(self.database.get_user_birth_profile(other["id"]), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tests/test_stock_detail_realtime.py b/app/tests/test_stock_detail_realtime.py index cd98358..c6760b6 100644 --- a/app/tests/test_stock_detail_realtime.py +++ b/app/tests/test_stock_detail_realtime.py @@ -90,8 +90,8 @@ class StockDetailRealtimeTests(unittest.TestCase): "moneyflow": {}, } - with patch("server.datetime", FixedMarketDatetime), patch( - "server.TushareClient", RealtimeClientStub + with patch("backend.application.datetime", FixedMarketDatetime), patch( + "backend.application.TushareClient", RealtimeClientStub ): result = self.service._prepare_stock_detail(cached, "002141", today) @@ -112,8 +112,8 @@ class StockDetailRealtimeTests(unittest.TestCase): "stock": {"code": "002141", "price": 10, "change": 1.2}, "prices": [{"trade_date": historical, "close": 10, "change": 1.2}], } - with patch("server.datetime", FixedMarketDatetime), patch( - "server.TushareClient", RealtimeClientStub + with patch("backend.application.datetime", FixedMarketDatetime), patch( + "backend.application.TushareClient", RealtimeClientStub ): result = self.service._prepare_stock_detail(payload, "002141", historical) @@ -151,8 +151,8 @@ class StockDetailRealtimeTests(unittest.TestCase): }, ], } - with patch("server.datetime", FixedPreopenDatetime), patch( - "server.TushareClient", RealtimeClientStub + with patch("backend.application.datetime", FixedPreopenDatetime), patch( + "backend.application.TushareClient", RealtimeClientStub ): result = self.service._prepare_stock_detail(payload, "002141", today) diff --git a/app/tools/build_api_registry.py b/app/tools/build_api_registry.py index 64704e9..e0011fc 100644 --- a/app/tools/build_api_registry.py +++ b/app/tools/build_api_registry.py @@ -60,7 +60,7 @@ def _role(method: str, path: str) -> str: def build() -> dict: - text = (ROOT / "server.py").read_text(encoding="utf-8") + text = (ROOT / "backend" / "application.py").read_text(encoding="utf-8") method_matches = list(re.finditer(r"^ def do_(GET|POST|DELETE)\(", text, re.MULTILINE)) routes = [] for index, match in enumerate(method_matches): diff --git a/docs/migration/evidence/slice-01/README.md b/docs/migration/evidence/slice-01/README.md new file mode 100644 index 0000000..5e7b27a --- /dev/null +++ b/docs/migration/evidence/slice-01/README.md @@ -0,0 +1,57 @@ +# 切片 01:启动、HTTP、账号、会员与系统管理 + +> 基线:`4083dce`(切片 00 原样可运行副本) +> 回档标签:`xiaobai-preservation-slice-01-20260731` +> 结论:自动差分通过;前端运行资产未改动,仍等待最终全站人工验收 + +## 1. 本次范围 + +本切片只整理原版 `app/` 副本中的启动、通用 HTTP、账号、会员和系统管理实现。所有实现均从 +原文件机械移动,原导入面由兼容外壳保持;没有从冻结的 `next/` 复制产品代码,也没有重新设计 +页面、接口或数据结构。 + +| 原位置 | 新的唯一实现位置 | 兼容方式 | +|---|---|---| +| `app/server.py` 的进程组装与完整应用服务 | `app/backend/application.py`、`app/backend/bootstrap/runtime.py` | `app/server.py` 继续导出原符号并保持原启动命令 | +| `app/server.py` 的 Cookie、鉴权、CSRF、静态文件和 JSON 传输 | `app/backend/http/handler.py` | `RequestHandler` 组合 `HttpTransportMixin` | +| `app/server.py` 的账号与会员 HTTP 方法 | `app/backend/features/accounts/http.py` | `RequestHandler` 组合 `AccountHttpMixin` | +| `app/server.py` 的系统管理 HTTP 方法 | `app/backend/features/system/http.py` | `RequestHandler` 组合 `SystemHttpMixin` | +| `app/server.py` 的账号、会员和出生信息业务方法 | `app/backend/features/accounts/service.py` | `DashboardService` 委托 `AccountService` | +| `app/database.py` 的账号、会话、会员与用户凭据方法 | `app/backend/features/accounts/repository.py` | `ReviewDatabase` 组合 `AccountRepositoryMixin` | +| `app/database.py` 的系统设置存取方法 | `app/backend/features/system/repository.py` | `ReviewDatabase` 组合 `SystemSettingsRepositoryMixin` | +| `app/security.py` | `app/backend/features/accounts/security.py` | 根文件保留原导出 | +| `app/app_config.py` | `app/backend/bootstrap/config.py` | 根文件保留原导出 | + +## 2. 不变量与差分证据 + +- API:`config/api.config.json` 与 `app/config/api.config.json` 的 SHA-256 相同;路由路径、方法、 + 访问角色、状态码和错误载荷由原注册表与全量测试继续约束。 +- 数据库:原版和迁移副本均为 36 张业务表、62 个 schema 对象,规范化 schema SHA-256 均为 + `0615a0423856d0eb02bccd81a071840bd50d6563da96d556cec49967ad8a5f4c`。 +- 账号边界:注册、登录、会话、密码、会员和出生信息使用临时数据库执行同一原版契约,5 项专项 + 测试全部通过。 +- 前端:本切片没有修改 `app/static/`;`index.html`、`app.js`、`styles.css` 与根目录原版对应文件 + 哈希相同。`app-light-1920x1080.png` 保存本切片运行截图。 +- 兼容:原 `python server.py` 命令以及测试和外部模块使用的 `server.DashboardService`、 + `server.RequestHandler`、`server.SERVICE`、`server.automatic_screener_jobs` 均保持可用。 + +## 3. 验证结果 + +| 验证 | 结果 | +|---|---:| +| `python -m unittest discover -s tests -q` | 236 项通过 | +| `python -m unittest tests.test_preservation_slice_accounts -v` | 5 项通过 | +| `npx playwright test --reporter=dot` | 45 项通过 | +| `python -m compileall -q ...` | 通过 | +| `git diff --check` | 通过(仅 Git 的 CRLF 提示) | + +Playwright 在自行创建 Python 静态服务器时存在 Windows 子进程退出等待问题;验证时预先启动 +`127.0.0.1:8876` 静态服务器并由 Playwright 复用,45 项用例在 143 秒内正常返回退出码 0。 + +## 4. 保留与待处理 + +- `app/backend/application.py` 仍包含其余尚未迁移切片的原版实现,这是刻意保留,不是本切片遗漏。 +- `app/server.py`、`app/app_config.py`、`app/security.py` 和 `app/database.py` 的兼容面须等所有消费者 + 完成归位后再评估;本阶段禁止删除。 +- 没有删除任何不确定代码,没有修改正式根目录数据库,也没有切换 Docker/NAS。 + diff --git a/docs/migration/evidence/slice-01/app-light-1920x1080.png b/docs/migration/evidence/slice-01/app-light-1920x1080.png new file mode 100644 index 0000000000000000000000000000000000000000..3d7c6f1472edbd1f04892c2be88a4123af620064 GIT binary patch literal 78754 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 Ag8%>k literal 0 HcmV?d00001 diff --git a/docs/migration/保真迁移状态.json b/docs/migration/保真迁移状态.json index 6ba3c44..b5919c8 100644 --- a/docs/migration/保真迁移状态.json +++ b/docs/migration/保真迁移状态.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "updated_at": "2026-07-30T23:55:00+08:00", + "updated_at": "2026-07-31T00:37:00+08:00", "status": "active", "migration_mode": "behavior_preserving_source_migration", "source_of_truth": "current_original_webapp_runtime_and_source", @@ -9,13 +9,14 @@ "failed_roots": [ "next" ], - "current_slice": null, - "last_completed_slice": "slice-00-exact-runtime-copy", - "last_checkpoint": "xiaobai-preservation-migration-charter-20260730", - "next_action": "commit_and_push_slice-00_then_begin_startup_http_account_migration", + "current_slice": "slice-02-market-search-charts-data", + "last_completed_slice": "slice-01-startup-http-accounts-system", + "last_checkpoint": "xiaobai-preservation-slice-01-20260731", + "next_action": "capture_slice-02_market_search_chart_data_contracts_then_move_original_implementations", "authoritative_documents": [ "AGENTS.md", "docs/migration/原版保真迁移总纲.md", + "docs/migration/目标目录与切片顺序.md", "docs/migration/保真迁移账本.md", "docs/migration/next失败冻结记录.md" ], diff --git a/docs/migration/保真迁移账本.md b/docs/migration/保真迁移账本.md index 260a167..351223e 100644 --- a/docs/migration/保真迁移账本.md +++ b/docs/migration/保真迁移账本.md @@ -1,6 +1,6 @@ # 小白复盘保真迁移账本 -> 当前状态:正式迁移,切片00“原样可运行副本”已完成 +> 当前状态:正式迁移,切片01“启动、HTTP、账号、会员与系统管理”已完成 本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及 `保真迁移状态.json`。 @@ -20,6 +20,7 @@ | 2026-07-30 | `xiaobai-next-rejected-20260730` | 冻结失败的`next/`实现 | 禁止部署或继续开发 | | 2026-07-30 | `xiaobai-preservation-migration-charter-20260730` | 建立保真迁移总纲、状态和恢复协议 | 尚未开始新迁移 | | 2026-07-30 | `41329943c4878fc09ed82ec376eb93ab151e4092` | 完成只读资产清查并由用户批准`app/`结构 | 开始切片00 | +| 2026-07-31 | `xiaobai-preservation-slice-01-20260731` | 启动、HTTP、账号、会员与系统管理原实现归位 | 自动差分通过,进入切片02 | ## 资产处置登记 @@ -50,6 +51,16 @@ - 完整证据:`docs/migration/evidence/slice-00/README.md`。 - 资产清单:`docs/migration/原版资产清单.json`,388项,0项哈希差异。 +已完成切片:`slice-01-startup-http-accounts-system`。 + +- 原版基线:提交`4083dce`,即切片00原样副本。 +- 迁移范围:进程入口、应用组装、HTTP传输、账号安全、账号/会员业务、账号/系统设置持久化。 +- 兼容边界:根级`server.py`、`app_config.py`、`security.py`继续保留原导入和命令入口。 +- API与数据库:API/功能注册表哈希一致;原版与副本数据库均为36表、62个schema对象且schema哈希一致。 +- 验收:236项Python测试、5项切片专项测试、45项Playwright测试全部通过;前端运行资产未改动。 +- 回档:标签`xiaobai-preservation-slice-01-20260731`。 +- 完整证据:`docs/migration/evidence/slice-01/README.md`。 + ## 决策记录 | 日期 | 决策 | 原因 | diff --git a/docs/migration/目标目录与切片顺序.md b/docs/migration/目标目录与切片顺序.md new file mode 100644 index 0000000..e0af03b --- /dev/null +++ b/docs/migration/目标目录与切片顺序.md @@ -0,0 +1,71 @@ +# 保真迁移目标目录与切片顺序 + +> 状态:已由用户确认 +> 生效日期:2026-07-30 + +本文件把已批准的`app/`目标结构和迁移顺序固化为可恢复约束。目录只表达职责,迁移时从 +原版复制、移动和拆分真实实现;禁止先建立空业务骨架后按规格书重写。 + +## 目标目录 + +```text +app/ + server.py # 最终仅保留进程入口与HTTP服务器组装 + backend/ + bootstrap/ # 路径、环境、配置和依赖容器 + http/ # 路由、鉴权、响应、静态资源和通用传输能力 + features/ + accounts/ # 登录、账号、会员及用户身份 + system/ # 管理员配置和运行状态 + market/ # 公共行情、搜索、详情和图表 + sentiment/ # 情绪周期 + pools/ # 涨停、炸板、跌停、昨日涨停和涨停表现 + ladder/ # 市场天梯 + rotation/ # 板块轮动 + auction/ # 集合竞价 + themes/ # 题材库 + popularity/ # 人气热榜 + dragon_tiger/ # 龙虎榜与游资档案 + screener/ # 阶段、策略、自定义选股及持续跟踪 + mentor/ # 问师 + heaven/ # 观势、观气、观心 + review/ # 自选、复盘笔记、交易日志和复盘助手 + alerts/ # 提醒中心 + data/ # 统一数据网关、口径、质量和供应商适配 + database/ # 连接、迁移和领域Repository + jobs/ # 后台任务定义、状态、调度和重试 + llm/ # 唯一模型网关、流式协议和调用审计 + frontend/ + index.html # 原版DOM骨架;仅在等价验证后拆分 + shared/ # API、状态、Shell、弹窗和通用组件 + pages// # 页面自己的行为与样式 + styles/ # 令牌、基础层、Shell和经验证后的公共样式 + vendor/ # 浏览器端第三方静态资产 + config/ # 页面、功能、API、任务及数据字段注册表 + data/ # SQLite及私有运行数据,保持Git忽略 + tests/ # 单元、契约、差异和浏览器回归 + tools/ # 清查、迁移、差异验证和维护工具 + vendor/ # Python离线依赖 + 游资skills/ # 原版公开Skill;私有Skill仍位于data/ +``` + +允许迁移期间保留根级兼容外壳;外壳只能转发到唯一实现,并须在账本登记删除条件。 + +## 固定切片顺序 + +| 切片 | 完整纵向范围 | +|---:|---| +| 00 | 原版可运行副本、资产清单、数据库副本和视觉基线 | +| 01 | 启动、HTTP通用能力、登录账号、会员和系统管理 | +| 02 | 公共行情、全局搜索、详情、悬浮图表、数据网关和数据质量 | +| 03 | 情绪周期、五类股池和涨停表现 | +| 04 | 市场天梯和板块轮动 | +| 05 | 集合竞价、题材库、人气热榜和龙虎榜 | +| 06 | 智能选股、自定义选股和策略持续跟踪 | +| 07 | 问师、模型Skill和LLM流式链路 | +| 08 | 问天:观势、观气和观心 | +| 09 | 我的复盘、自选、笔记、交易日志、提醒和复盘助手 | +| 10 | 前端Shell、页面文件、共享组件、CSS层级和移动端职责归位 | +| 11 | 待定代码试删、全量并行验收、维护文档和切换准备 | + +每个切片先记录原版证据,再移动实现,再执行同输入差异;未通过时不得进入下一切片。