from __future__ import annotations import argparse import copy import json import mimetypes import re import secrets import threading import time from datetime import date, datetime, time as dt_time, timedelta, timezone from http import HTTPStatus from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any from urllib.parse import parse_qs, unquote, urlparse from assistant_agent import ReviewAssistantError, stream_review_assistant from api_access import ROUTES from backend.bootstrap import build_application_container, load_runtime_settings from backend.http import correlation_id, normalize_error_payload from backend.llm import LLMGateway, LLMGatewayError from chart_data_provider import ChartDataError from app_config import ( DATA_DIR, MENTOR_SKILLS_DIR, PRIVATE_MENTOR_SKILLS_DIR, SESSION_COOKIE, SESSION_MAX_AGE, STATIC_DIR, TOKEN_PATTERN, USERNAME_PATTERN, add_months as _add_months, membership_boundary as _membership_boundary, normalize_date, parse_iso_datetime as _parse_iso_datetime, tushare_code, validate_stock_code, validate_text, ) from database import ReviewDatabase from heaven_agent import HeavenAgentError, interpret_heaven from heaven_engine import ( _market_line_scores, _score_to_line, build_five_phase_field, build_market_hexagram, build_personal_field, hexagram_from_lines, ) from ifind_client import IfindError from llm_strategy import LLMCompilerError, compile_strategy_with_llm, test_llm_connection from mentor_agent import MentorAgentError, stream_with_mentor from market_insights import MarketInsightsService from screener import ( FACTOR_FIELDS, FACTOR_GROUPS, REGIMES, FactorDataService, compile_local_strategy, ) from security import SecretVault, hash_password, token_hash, verify_password from sentiment_engine import ( COMPONENT_WEIGHTS, SENTIMENT_ENGINE_VERSION, apply_sentiment_to_dashboard, build_sentiment_history, latest_contiguous_history, ) from tushare_client import TushareClient, TushareError, _sector_coverage_issue SCREENER_LIBRARY_VERSION = 8 def automatic_screener_jobs( strategies: list[dict[str, Any]], regime_id: str ) -> list[dict[str, Any]]: """Build the close-of-day jobs; only stage screening is regime-gated.""" smart_strategy = next( ( item for item in strategies if item.get("formula", {}).get("meta", {}).get("library") != "curated" and regime_id in (item.get("regimes") or []) ), None, ) curated = [ item for item in strategies if item.get("formula", {}).get("meta", {}).get("library") == "curated" ] jobs = ([{"mode": "smart", "strategy": smart_strategy}] if smart_strategy else []) jobs.extend({"mode": "curated", "strategy": item} for item in curated) return jobs LEGACY_SECRET_KEYS = { "TUSHARE_TOKEN", "IFIND_REFRESH_TOKEN", "IFIND_ACCESS_TOKEN", "LLM_API_KEY", "LLM_BASE_URL", "LLM_MODEL", "LLM_PRIMARY_API_KEY", "LLM_PRIMARY_BASE_URL", "LLM_PRIMARY_MODEL", "LLM_FALLBACK_API_KEY", "LLM_FALLBACK_BASE_URL", "LLM_FALLBACK_MODEL", } SEARCH_INDEXES = ( {"id": "000001.SH", "code": "000001.SH", "name": "上证指数", "type": "index", "subtitle": "沪市综合指数"}, {"id": "399001.SZ", "code": "399001.SZ", "name": "深证成指", "type": "index", "subtitle": "深市成份指数"}, {"id": "399006.SZ", "code": "399006.SZ", "name": "创业板指", "type": "index", "subtitle": "创业板核心指数"}, ) SEARCH_TYPE_LABELS = { "stock": "股票", "sector": "板块", "theme": "题材", "index": "指数", } THS_SEARCH_TYPES = { "I": ("sector", "行业板块"), "R": ("sector", "地域板块"), "N": ("theme", "概念题材"), } MENTOR_DATA_PROFILES = { "emotion": { "kobe92-perspective", "niepanchongsheng-perspective", "chaojiyangjia-perspective", "tuixuechaogu-perspective", "chenxiaoqun-perspective", "zhiyechaoshou-perspective", }, "first_board": { "beijingchaojia-perspective", "chuangshiji-perspective", "xuxiang-perspective", "foshanwuyingjiao-perspective", }, "leader": { "zhaolaoge-perspective", "fangxinxia-perspective", "xiaoe-perspective", "sunge-perspective", "liuyizhonglu-perspective", }, "trend": { "zhangdetao-perspective", "zhangmengzhu-perspective", "zuoshouxinyi-perspective", }, "low_absorption": { "qiaobangzhu-perspective", "asking-perspective", "longfeihu-perspective", "ruihexian-perspective", }, "macro": {"shuipi-perspective"}, } MENTOR_INDEX_UNIVERSE = ( ("000001.SH", "上证指数"), ("399001.SZ", "深证成指"), ("399006.SZ", "创业板指"), ("000016.SH", "上证50"), ("000300.SH", "沪深300"), ("000905.SH", "中证500"), ("000852.SH", "中证1000"), ("932000.CSI", "中证2000"), ) MENTOR_ETF_UNIVERSE = ( ("510050.SH", "上证50ETF"), ("510300.SH", "沪深300ETF"), ("510500.SH", "中证500ETF"), ("512100.SH", "中证1000ETF"), ) class DashboardService: def __init__(self) -> None: runtime = load_runtime_settings() self.vault = SecretVault(runtime.encryption_key) self.database = ReviewDatabase(DATA_DIR / "review.db") self.sync_lock = threading.Lock() self.auth_lock = threading.Lock() self.system_lock = threading.Lock() self.auto_screener_lock = threading.Lock() self._auto_screener_last_attempt: dict[str, datetime] = {} self._ifind_event_lock = threading.Lock() self._request_context = threading.local() self._system_credentials = self._load_system_credentials(runtime.initial_credentials) self.container = build_application_container( self.database, self._system_credentials, MENTOR_SKILLS_DIR, PRIVATE_MENTOR_SKILLS_DIR, lambda: self.token, ) self.data_gateway = self.container.data_gateway self.ifind = self.container.ifind self.screener = self.container.screener self.strategy_tracking = self.container.strategy_tracking self.alert_service = self.container.alert_service self.trade_journal = self.container.trade_journal self.mentor_skills = self.container.mentor_skills self.realtime_aggregator = self.container.realtime_aggregator self.chart_data = self.container.chart_data self.jobs = self.container.jobs self.llm_gateway = LLMGateway( database=self.database, user_id_supplier=lambda: self.current_user_id, membership_supplier=self.membership, settings_supplier=lambda: self._system_credentials, profile_supplier=self._resolved_llm_profile, ) self.screener.ensure_builtin_strategies() self._background_stop = threading.Event() self._background_thread = self.jobs.start_scheduler( self._background_refresh_tick, self._background_stop, interval_seconds=5, initial_delay_seconds=3, ) def _tushare_client(self) -> TushareClient: gateway = getattr(self, "data_gateway", None) if gateway is not None: return gateway.tushare() # Compatibility for isolated legacy unit-test service stubs. return TushareClient(self.token) def _load_system_credentials(self, environment: dict[str, str]) -> dict[str, Any]: encrypted = self.database.get_system_setting("credentials") current = self.vault.decrypt_json(encrypted) if encrypted else {} changed = False first_user_id = self.database.first_user_id() first_personal: dict[str, Any] = {} if first_user_id: first_encrypted = self.database.get_user_credentials(first_user_id) first_personal = self.vault.decrypt_json(first_encrypted) if first_encrypted else {} defaults = { "tushare_token": environment.get("tushare_token") or first_personal.get("tushare_token") or "", "ifind_refresh_token": environment.get("ifind_refresh_token") or "", "ifind_access_token": environment.get("ifind_access_token") or "", "platform_llm_primary_api_key": environment.get("platform_llm_primary_api_key") or first_personal.get("llm_primary_api_key") or "", "platform_llm_primary_base_url": environment.get("platform_llm_primary_base_url") or first_personal.get("llm_primary_base_url") or "https://api.openai.com/v1", "platform_llm_primary_model": environment.get("platform_llm_primary_model") or first_personal.get("llm_primary_model") or "", "platform_llm_fallback_api_key": environment.get("platform_llm_fallback_api_key") or first_personal.get("llm_fallback_api_key") or "", "platform_llm_fallback_base_url": environment.get("platform_llm_fallback_base_url") or first_personal.get("llm_fallback_base_url") or "", "platform_llm_fallback_model": environment.get("platform_llm_fallback_model") or first_personal.get("llm_fallback_model") or "", "member_daily_limit": 50, "background_refresh_enabled": True, } for key, value in defaults.items(): if key not in current: current[key] = value changed = True if not isinstance(current.get("llm_models"), list): migrated_models: list[dict[str, str]] = [] for role, label in (("primary", "原主模型"), ("fallback", "原辅助模型")): profile = { "api_key": str(current.get(f"platform_llm_{role}_api_key") or ""), "base_url": str(current.get(f"platform_llm_{role}_base_url") or ""), "model": str(current.get(f"platform_llm_{role}_model") or ""), } if profile["api_key"] or profile["model"]: model_id = f"migrated-{role}" migrated_models.append( {"id": model_id, "name": label, **profile} ) current[f"{role}_model_id"] = model_id current["llm_models"] = migrated_models current.setdefault("primary_model_id", "") current.setdefault("fallback_model_id", "") changed = True if changed or not encrypted: self.database.save_system_setting("credentials", self.vault.encrypt_json(current)) for row in self.database.list_user_credentials(): personal = self.vault.decrypt_json(str(row.get("encrypted_payload") or "")) if "tushare_token" in personal: personal.pop("tushare_token", None) self.database.save_user_credentials( int(row["user_id"]), self.vault.encrypt_json(personal) ) return current def _save_system_credentials(self, credentials: dict[str, Any]) -> None: with self.system_lock: self.database.save_system_setting("credentials", self.vault.encrypt_json(credentials)) self._system_credentials = dict(credentials) if hasattr(self, "ifind"): self.ifind.set_credentials( str(credentials.get("ifind_refresh_token") or ""), str(credentials.get("ifind_access_token") or ""), ) @property def configured(self) -> bool: return bool(self.token) def bind_user(self, user_id: int) -> None: self._request_context.user_id = int(user_id) encrypted = self.database.get_user_credentials(int(user_id)) self._request_context.credentials = self.vault.decrypt_json(encrypted) if encrypted else {} self._request_context.access = self.database.user_access(int(user_id)) or {} @property def current_user_id(self) -> int: user_id = getattr(self._request_context, "user_id", 0) if not user_id: raise ValueError("当前请求尚未绑定账号。") return int(user_id) def _credentials(self) -> dict[str, str]: credentials = getattr(self._request_context, "credentials", {}) return { "llm_primary_api_key": str(credentials.get("llm_primary_api_key") or ""), "llm_primary_base_url": str( credentials.get("llm_primary_base_url") or "https://api.openai.com/v1" ), "llm_primary_model": str(credentials.get("llm_primary_model") or ""), "llm_fallback_api_key": str(credentials.get("llm_fallback_api_key") or ""), "llm_fallback_base_url": str(credentials.get("llm_fallback_base_url") or ""), "llm_fallback_model": str(credentials.get("llm_fallback_model") or ""), } def _save_credentials(self, credentials: dict[str, str]) -> None: self.database.save_user_credentials( self.current_user_id, self.vault.encrypt_json(credentials), ) self._request_context.credentials = dict(credentials) @property def token(self) -> str: return str(self._system_credentials.get("tushare_token") or "") def _personal_llm_profile(self) -> dict[str, Any]: credentials = self._credentials() return { "source": "personal", "primary": { "api_key": credentials["llm_primary_api_key"], "base_url": credentials["llm_primary_base_url"], "model": credentials["llm_primary_model"], }, "fallback": { "api_key": credentials["llm_fallback_api_key"], "base_url": credentials["llm_fallback_base_url"], "model": credentials["llm_fallback_model"], }, } def _platform_llm_profile(self) -> dict[str, Any]: models = { str(item.get("id") or ""): item for item in self._system_credentials.get("llm_models") or [] if isinstance(item, dict) and item.get("id") } def selected(role: str) -> dict[str, str]: item = models.get(str(self._system_credentials.get(f"{role}_model_id") or ""), {}) return { "id": str(item.get("id") or ""), "name": str(item.get("name") or ""), "api_key": str(item.get("api_key") or ""), "base_url": str(item.get("base_url") or ""), "model": str(item.get("model") or ""), } return { "source": "platform", "primary": selected("primary"), "fallback": selected("fallback"), } @staticmethod def _profile_configured(profile: dict[str, str]) -> bool: return bool(profile.get("api_key") and profile.get("base_url") and profile.get("model")) def membership(self) -> dict[str, Any]: access = getattr(self._request_context, "access", {}) or self.database.user_access(self.current_user_id) or {} now = datetime.now(timezone.utc) starts = _parse_iso_datetime(access.get("membership_starts_at")) expires = _parse_iso_datetime(access.get("membership_expires_at")) subscribed = ( access.get("membership_status") == "active" and (not starts or starts <= now) and (not expires or expires > now) ) is_admin = str(access.get("role")) == "admin" active = is_admin or subscribed remaining_seconds = None if expires: remaining_seconds = max(0, int((expires - now).total_seconds())) return { "active": active, "subscribed": subscribed, "status": "active" if subscribed else str(access.get("membership_status") or "inactive"), "plan": str(access.get("membership_plan") or ""), "starts_at": str(access.get("membership_starts_at") or ""), "expires_at": str(access.get("membership_expires_at") or ""), "is_admin": is_admin, "remaining_seconds": remaining_seconds, "remaining_days": None if remaining_seconds is None else (remaining_seconds + 86399) // 86400, } def _resolved_llm_profile(self) -> dict[str, Any]: platform = self._platform_llm_profile() platform_ready = self.membership()["active"] and self._profile_configured(platform["primary"]) if platform_ready: return platform return {"source": "none", "primary": {}, "fallback": {}} @property def llm_primary_api_key(self) -> str: return str(self._resolved_llm_profile()["primary"].get("api_key") or "") @property def llm_primary_base_url(self) -> str: return str(self._resolved_llm_profile()["primary"].get("base_url") or "") @property def llm_primary_model(self) -> str: return str(self._resolved_llm_profile()["primary"].get("model") or "") @property def llm_fallback_api_key(self) -> str: return str(self._resolved_llm_profile()["fallback"].get("api_key") or "") @property def llm_fallback_base_url(self) -> str: return str(self._resolved_llm_profile()["fallback"].get("base_url") or "") @property def llm_fallback_model(self) -> str: return str(self._resolved_llm_profile()["fallback"].get("model") or "") @property def llm_source(self) -> str: return str(self._resolved_llm_profile().get("source") or "none") @property def llm_configured(self) -> bool: return bool(self.llm_primary_api_key and self.llm_primary_model) @property def llm_fallback_configured(self) -> bool: return bool( self.llm_fallback_api_key and self.llm_fallback_base_url and self.llm_fallback_model ) def save_llm_settings( self, primary: dict[str, Any], fallback: dict[str, Any], fallback_enabled: bool, ) -> None: personal = self._personal_llm_profile() primary_profile = self._validate_llm_profile( primary, personal["primary"], required=True, label="主模型", ) if fallback_enabled: fallback_profile = self._validate_llm_profile( fallback, personal["fallback"], required=True, label="辅助模型", ) else: fallback_profile = {"api_key": "", "base_url": "", "model": ""} credentials = self._credentials() credentials.update( { "llm_primary_api_key": primary_profile["api_key"], "llm_primary_base_url": primary_profile["base_url"], "llm_primary_model": primary_profile["model"], "llm_fallback_api_key": fallback_profile["api_key"], "llm_fallback_base_url": fallback_profile["base_url"], "llm_fallback_model": fallback_profile["model"], } ) self._save_credentials(credentials) def save_llm_mode(self, mode: str) -> None: raise ValueError("LLM 算力由管理员统一配置,会员账号自动使用平台模型。") def test_llm_profile(self, role: str, payload: dict[str, Any]) -> dict[str, Any]: personal = self._personal_llm_profile() if role == "primary": current = personal["primary"] label = "主模型" elif role == "fallback": current = personal["fallback"] label = "辅助模型" else: raise ValueError("模型角色不支持。") profile = self._validate_llm_profile(payload, current, required=True, label=label) try: return self.llm_gateway.probe( profile, lambda model: test_llm_connection( model.api_key, model.base_url, model.model ), ) except LLMCompilerError as exc: raise ValueError(str(exc)) from exc @staticmethod def _validate_llm_profile( payload: dict[str, Any], current: dict[str, str], required: bool, label: str, ) -> dict[str, str]: api_key = str(payload.get("api_key") or current.get("api_key") or "").strip() base_url = str(payload.get("base_url") or current.get("base_url") or "").strip().rstrip("/") model = str(payload.get("model") or current.get("model") or "").strip() if not required and not any((api_key, base_url, model)): return {"api_key": "", "base_url": "", "model": ""} parsed = urlparse(base_url) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise ValueError(f"{label} Base URL 格式不正确。") if not api_key or len(api_key) > 300: raise ValueError(f"{label} API Key 不能为空或过长。") if not model or len(model) > 100: raise ValueError(f"{label}模型名称不能为空或过长。") return {"api_key": api_key, "base_url": base_url, "model": model} def llm_access_status(self) -> dict[str, Any]: platform = self._platform_llm_profile() membership = self.membership() limit = max(1, int(self._system_credentials.get("member_daily_limit") or 50)) used = self._platform_usage_today() if membership["active"] else 0 resolved = self._resolved_llm_profile() return { "mode": "platform" if membership["active"] else "locked", "resolved_source": resolved.get("source") or "none", "resolved_model": str(resolved.get("primary", {}).get("model") or ""), "platform_configured": self._profile_configured(platform["primary"]), "membership": membership, "daily_limit": limit, "used_today": used, "remaining_calls": None if membership["is_admin"] else max(0, limit - used), } def _platform_usage_today(self) -> int: now = datetime.now().astimezone() start = now.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc) return self.database.count_llm_usage_since( self.current_user_id, "platform", start.isoformat(timespec="seconds"), ) def system_status(self) -> dict[str, Any]: platform = self._platform_llm_profile() model_pool = [] for item in self._system_credentials.get("llm_models") or []: if not isinstance(item, dict): continue profile = { "api_key": str(item.get("api_key") or ""), "base_url": str(item.get("base_url") or ""), "model": str(item.get("model") or ""), } model_pool.append( { "id": str(item.get("id") or ""), "name": str(item.get("name") or ""), "base_url": profile["base_url"], "model": profile["model"], "configured": self._profile_configured(profile), } ) return { "data": { "configured": self.configured, "ifind": self.ifind.status(), "background_refresh_enabled": bool( self._system_credentials.get("background_refresh_enabled", True) ), **self.database.status(), "jobs": self.jobs.repository.recent(12), }, "llm": { "primary_configured": self._profile_configured(platform["primary"]), "fallback_configured": self._profile_configured(platform["fallback"]), "models": model_pool, "primary_model_id": str(self._system_credentials.get("primary_model_id") or ""), "fallback_model_id": str(self._system_credentials.get("fallback_model_id") or ""), }, "membership": { "member_daily_limit": max( 1, int(self._system_credentials.get("member_daily_limit") or 50) ) }, } def save_system_settings(self, payload: dict[str, Any]) -> dict[str, Any]: current = dict(self._system_credentials) token = str(payload.get("tushare_token") or current.get("tushare_token") or "").strip() if token and not TOKEN_PATTERN.fullmatch(token): raise ValueError("Tushare Token 格式不正确。") ifind_refresh_token = str( payload.get("ifind_refresh_token") or current.get("ifind_refresh_token") or "" ).strip() if ifind_refresh_token and ( len(ifind_refresh_token) > 2048 or any(character.isspace() for character in ifind_refresh_token) ): raise ValueError("iFinD Refresh Token 格式不正确。") existing_models = { str(item.get("id") or ""): item for item in current.get("llm_models") or [] if isinstance(item, dict) and item.get("id") } raw_models = payload.get("models") models: list[dict[str, str]] = [] if raw_models is not None: if not isinstance(raw_models, list) or len(raw_models) > 20: raise ValueError("模型池格式不正确,最多可保存 20 个模型。") seen_ids: set[str] = set() seen_names: set[str] = set() for index, raw in enumerate(raw_models, start=1): if not isinstance(raw, dict): raise ValueError("模型池条目格式不正确。") model_id = str(raw.get("id") or f"model-{secrets.token_hex(6)}").strip() if not re.fullmatch(r"[A-Za-z0-9_-]{3,80}", model_id) or model_id in seen_ids: raise ValueError("模型 ID 不正确或重复。") name = validate_text(raw.get("name"), f"模型 {index} 名称", 50, required=True) normalized_name = name.casefold() if normalized_name in seen_names: raise ValueError("模型名称不能重复。") profile = self._validate_llm_profile( raw, existing_models.get(model_id) or {}, required=True, label=name, ) models.append({"id": model_id, "name": name, **profile}) seen_ids.add(model_id) seen_names.add(normalized_name) else: models = [dict(item) for item in existing_models.values()] model_ids = {item["id"] for item in models} primary_model_id = str( payload.get("primary_model_id", current.get("primary_model_id") or "") or "" ).strip() fallback_model_id = str( payload.get("fallback_model_id", current.get("fallback_model_id") or "") or "" ).strip() if models and primary_model_id not in model_ids: raise ValueError("请从模型池选择主模型。") if not models: primary_model_id = "" fallback_model_id = "" if fallback_model_id and fallback_model_id not in model_ids: raise ValueError("辅助模型不在模型池中。") if fallback_model_id and fallback_model_id == primary_model_id: raise ValueError("主模型与辅助模型不能相同。") try: daily_limit = max( 1, min( 1000, int(payload.get("member_daily_limit", current.get("member_daily_limit") or 50)), ), ) except (TypeError, ValueError) as exc: raise ValueError("会员每日额度应为 1 至 1000。") from exc current.update( { "tushare_token": token, "ifind_refresh_token": ifind_refresh_token, "llm_models": models, "primary_model_id": primary_model_id, "fallback_model_id": fallback_model_id, "member_daily_limit": daily_limit, "background_refresh_enabled": bool( payload.get( "background_refresh_enabled", current.get("background_refresh_enabled", True), ) ), } ) self._save_system_credentials(current) return self.system_status() def test_system_llm_profile(self, model_id: str, payload: dict[str, Any]) -> dict[str, Any]: current = next( ( item for item in self._system_credentials.get("llm_models") or [] if str(item.get("id") or "") == model_id ), {}, ) label = validate_text(payload.get("name") or current.get("name"), "模型名称", 50, required=True) profile = self._validate_llm_profile( payload, current, required=True, label=label ) try: return self.llm_gateway.probe( profile, lambda model: test_llm_connection( model.api_key, model.base_url, model.model ), ) except LLMCompilerError as exc: raise ValueError(str(exc)) from exc def admin_users(self) -> list[dict[str, Any]]: original_user_id = getattr(self._request_context, "user_id", 0) original_credentials = getattr(self._request_context, "credentials", {}) original_access = getattr(self._request_context, "access", {}) rows = [] try: for user in self.database.list_users(): self._request_context.user_id = int(user["id"]) self._request_context.access = user membership = self.membership() used = self._platform_usage_today() if membership["active"] else 0 rows.append({ **user, "membership_active": membership["active"], "membership_subscribed": membership["subscribed"], "used_today": used, }) finally: self._request_context.user_id = original_user_id self._request_context.credentials = original_credentials self._request_context.access = original_access return rows def update_membership(self, payload: dict[str, Any]) -> None: try: user_id = int(payload.get("user_id")) except (TypeError, ValueError) as exc: raise ValueError("会员账号不正确。") from exc status = str(payload.get("status") or "inactive") if status not in {"active", "inactive", "suspended"}: raise ValueError("会员状态不正确。") access = self.database.user_access(user_id) if not access: raise ValueError("用户不存在。") starts_at = None expires_at = None plan = "" if status == "active": duration = str(payload.get("duration") or "").strip() durations = { "1_month": (1, "1个月"), "3_months": (3, "3个月"), "12_months": (12, "12个月"), "3_years": (36, "3年"), "permanent": (0, "永久"), } if duration not in durations: raise ValueError("请选择会员开通时长。") now = datetime.now(timezone.utc) existing_start = _parse_iso_datetime(access.get("membership_starts_at")) existing_expiry = _parse_iso_datetime(access.get("membership_expires_at")) starts = existing_start if existing_start and existing_start <= now else now months, plan = durations[duration] starts_at = starts.isoformat(timespec="seconds") if months: renewal_base = existing_expiry if existing_expiry and existing_expiry > now else now expires_at = _add_months(renewal_base, months).isoformat(timespec="seconds") if not self.database.update_membership( user_id, status, plan, starts_at, expires_at ): raise ValueError("用户不存在。") def request_background_sync(self, trade_date: str) -> bool: normalized = normalize_date(trade_date) key = f"manual:{normalized}:{time.time_ns()}" return self.jobs.submit( "market.refresh", key, lambda: self.sync_dashboard(normalized), {"trade_date": normalized, "trigger": "administrator"}, ) def _background_refresh_tick(self) -> None: if not ( self.configured and self._system_credentials.get("background_refresh_enabled", True) ): return today = date.today().strftime("%Y%m%d") snapshot = self.database.get_snapshot(today) or {} if self._realtime_snapshot_due(today, snapshot): bucket = int(time.time() // 5) self.jobs.submit( "market.refresh", f"realtime:{today}:{bucket}", lambda: self.sync_dashboard(today), {"trade_date": today, "trigger": "realtime-poll"}, ) self._schedule_automatic_screeners(today, snapshot) def register_account(self, username: str, password: str) -> dict[str, Any]: username = username.strip() self._validate_account_input(username, password) with self.auth_lock: salt, password_digest = hash_password(password) user = self.database.create_user(username, salt, password_digest) return self.create_account_session(user) def login_account(self, username: str, password: str) -> dict[str, Any]: username = username.strip() if not username or not password: raise ValueError("账号名和密码不能为空。") user = self.database.user_by_username(username) if not user or not verify_password( password, str(user.get("password_salt") or ""), str(user.get("password_hash") or ""), ): raise ValueError("账号名或密码不正确。") return self.create_account_session(user) def change_password(self, current_password: str, new_password: str) -> None: current_password = str(current_password or "") self._validate_account_input(str(self.database.user_access(self.current_user_id)["username"]), new_password) credentials = self.database.user_password(self.current_user_id) if not credentials or not verify_password( current_password, str(credentials.get("password_salt") or ""), str(credentials.get("password_hash") or ""), ): raise ValueError("当前密码不正确。") salt, digest = hash_password(new_password) if not self.database.update_user_password(self.current_user_id, salt, digest): raise ValueError("账号不存在。") def create_account_session(self, user: dict[str, Any]) -> dict[str, Any]: session_token = secrets.token_urlsafe(32) csrf_token = secrets.token_urlsafe(24) expires = datetime.now(timezone.utc) + timedelta(seconds=SESSION_MAX_AGE) self.database.create_session( token_hash(session_token), int(user["id"]), csrf_token, expires.isoformat(timespec="seconds"), ) self.bind_user(int(user["id"])) access = self.database.user_access(int(user["id"])) or {} return { "user": { "id": int(user["id"]), "username": str(user["username"]), "role": str(access.get("role") or "user"), "membership": self.membership(), }, "session_token": session_token, "csrf_token": csrf_token, } @staticmethod def _validate_account_input(username: str, password: str) -> None: if not USERNAME_PATTERN.fullmatch(username): raise ValueError("账号名应为 3 至 30 位中文、字母、数字、下划线或连字符。") if len(password) < 8 or len(password) > 128: raise ValueError("密码长度应为 8 至 128 位。") if password.isalpha() or password.isdigit(): raise ValueError("密码应同时包含字母、数字或符号中的至少两类。") def save_birth_profile(self, payload: dict[str, Any]) -> dict[str, Any]: birth_datetime = str(payload.get("birth_datetime") or "").strip() gender = str(payload.get("gender") or "unspecified").strip() current_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat())) personal = build_personal_field(birth_datetime, gender, current_date) encrypted = self.vault.encrypt_json( {"birth_datetime": birth_datetime, "gender": gender} ) self.database.save_user_birth_profile(self.current_user_id, encrypted) return self._public_personal_profile(personal) def stored_birth_profile(self) -> dict[str, str] | None: encrypted = self.database.get_user_birth_profile(self.current_user_id) if not encrypted: return None payload = self.vault.decrypt_json(encrypted) birth_datetime = str(payload.get("birth_datetime") or "").strip() if not birth_datetime: return None return { "birth_datetime": birth_datetime, "gender": str(payload.get("gender") or "unspecified"), } def account_personal_field( self, current_date: str, current_field: dict[str, Any], public: bool = False, ) -> dict[str, Any] | None: stored = self.stored_birth_profile() if not stored: return None personal = build_personal_field( stored["birth_datetime"], stored["gender"], current_date, current_field, ) if public: return self._public_personal_profile(personal) personal.pop("birth", None) return personal @staticmethod def _public_personal_profile(personal: dict[str, Any]) -> dict[str, Any]: allowed = { "day_master", "ten_god_tendency", "element_balance", "balance_tendency", "current", "notice", } return {key: value for key, value in personal.items() if key in allowed} def get_dashboard(self, trade_date: str, force: bool = False) -> dict[str, Any]: normalized_date = normalize_date(trade_date) now = datetime.now().astimezone() if ( normalized_date == now.strftime("%Y%m%d") and now.time().replace(tzinfo=None) < datetime.strptime("09:15", "%H:%M").time() ): previous = self.database.get_latest_real_snapshot(normalized_date, strictly_before=True) if previous: carried = self._carry_dashboard(previous, normalized_date, "盘前沿用最近交易日收盘行情") return self._apply_reason_overrides(self._with_storage(carried, cached=True)) if not force: snapshot = self.database.get_snapshot(normalized_date) if snapshot and str((snapshot.get("meta") or {}).get("source") or "") != "demo": snapshot = copy.deepcopy(snapshot) if normalized_date != now.strftime("%Y%m%d"): snapshot.setdefault("meta", {}).update( {"realtime": False, "market_status": "closed"} ) if not self._dashboard_sentiment_ready(snapshot): snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date) self.database.save_snapshot( normalized_date, str((snapshot.get("meta") or {}).get("source") or "tushare"), snapshot, ) snapshot.setdefault("meta", {})["requested_date"] = self._display_compact_date(normalized_date) return self._apply_reason_overrides(self._with_storage(snapshot, cached=True)) resolved = self.database.get_data_snapshot( "dashboard_request_v1", normalized_date ) if resolved and str((resolved.get("meta") or {}).get("source") or "") != "demo": resolved = copy.deepcopy(resolved) resolved.setdefault("meta", {})["requested_date"] = self._display_compact_date( normalized_date ) return self._apply_reason_overrides( self._with_storage(resolved, cached=True) ) if datetime.strptime(normalized_date, "%Y%m%d").weekday() >= 5: previous = self.database.get_latest_real_snapshot(normalized_date) if previous: carried = self._carry_dashboard( previous, normalized_date, "非交易日沿用最近交易日收盘行情", ) self.database.save_data_snapshot( "dashboard_request_v1", normalized_date, "sqlite", carried ) return self._apply_reason_overrides( self._with_storage(carried, cached=True) ) return self.sync_dashboard(normalized_date) @staticmethod def _dashboard_sentiment_ready(dashboard: dict[str, Any]) -> bool: overview = dashboard.get("overview") or {} return int(overview.get("sentiment_engine_version") or 0) == SENTIMENT_ENGINE_VERSION and all( key in overview for key in ( "sentiment_score", "sentiment_label", "sentiment_phase", "sentiment_direction", "sentiment_components", ) ) @staticmethod def _display_compact_date(compact: str) -> str: return f"{compact[:4]}-{compact[4:6]}-{compact[6:8]}" def _carry_dashboard( self, snapshot: dict[str, Any], requested_date: str, reason: str ) -> dict[str, Any]: carried = copy.deepcopy(snapshot) meta = carried.setdefault("meta", {}) meta.update( { "requested_date": self._display_compact_date(requested_date), "carried_forward": True, "realtime": False, "market_status": "closed", "notice": reason, } ) return carried def _realtime_snapshot_due( self, normalized_date: str, snapshot: dict[str, Any], ) -> bool: if not self.configured or normalized_date != date.today().strftime("%Y%m%d"): return False now = datetime.now().astimezone() local_time = now.time().replace(tzinfo=None) realtime_start = datetime.strptime("09:15", "%H:%M").time() morning_end = datetime.strptime("11:35", "%H:%M").time() afternoon_start = datetime.strptime("12:55", "%H:%M").time() realtime_end = datetime.strptime("15:05", "%H:%M").time() in_session = ( realtime_start <= local_time < morning_end or afternoon_start <= local_time < realtime_end ) if not in_session: return False meta = snapshot.get("meta") or {} snapshot_trade_date = str(meta.get("trade_date") or "").replace("-", "") if snapshot_trade_date and snapshot_trade_date != normalized_date: return False if not meta.get("realtime"): return True try: updated_at = datetime.fromisoformat(str(meta.get("updated_at") or "")) if updated_at.tzinfo is None: updated_at = updated_at.replace(tzinfo=now.tzinfo) except ValueError: return True age_seconds = (now - updated_at.astimezone(now.tzinfo)).total_seconds() return age_seconds >= 8 def sync_dashboard(self, trade_date: str) -> dict[str, Any]: normalized_date = normalize_date(trade_date) source = "tushare" with self.sync_lock: sync_id = self.database.start_sync(normalized_date, source) try: if not self.configured: raise TushareError("公共行情尚未配置") dashboard = self._tushare_client().dashboard(normalized_date) dashboard["meta"]["source"] = source dashboard["meta"]["requested_date"] = self._display_compact_date(normalized_date) dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date) record_count = self._record_count(dashboard) actual_date = normalize_date( str(dashboard.get("meta", {}).get("trade_date") or normalized_date) ) self.database.save_snapshot(actual_date, source, dashboard) if actual_date != normalized_date: dashboard.setdefault("meta", {}).update( { "carried_forward": True, "realtime": False, "market_status": "closed", } ) self.database.save_data_snapshot( "dashboard_request_v1", normalized_date, source, dashboard ) self.database.finish_sync( sync_id, "success", record_count, dashboard.get("meta", {}).get("notice", ""), source, ) return self._apply_reason_overrides(self._with_storage(dashboard, cached=False)) except TushareError as exc: fallback = self.database.get_latest_real_snapshot(normalized_date) if fallback: carried = self._carry_dashboard( fallback, normalized_date, f"最新行情暂不可用,沿用最近收盘快照:{exc}" ) self.database.finish_sync( sync_id, "fallback", self._record_count(carried), str(exc), "tushare" ) return self._apply_reason_overrides(self._with_storage(carried, cached=True)) self.database.finish_sync(sync_id, "failed", message=str(exc)) raise ValueError("暂无可用的真实行情快照,请等待后台完成首次同步。") from exc except Exception as exc: self.database.finish_sync(sync_id, "failed", message=str(exc)) raise def _enrich_dashboard_sentiment( self, dashboard: dict[str, Any], end_date: str, ) -> dict[str, Any]: history = self.database.list_snapshot_payloads(end_date, 260) return apply_sentiment_to_dashboard(dashboard, history) def sentiment_history(self, trade_date: str, limit: int = 20) -> dict[str, Any]: normalized_date = normalize_date(trade_date) limit = max(10, min(120, int(limit))) full_series = build_sentiment_history( self.database.list_snapshot_payloads(normalized_date, 240) ) series = latest_contiguous_history(full_series) rows = series[-limit:] return { "trade_date": rows[-1]["trade_date"] if rows else normalized_date, "available_days": len(series), "stored_days": len(full_series), "requested_days": limit, "rows": rows, "weights": COMPONENT_WEIGHTS, "normalization": rows[-1]["normalization"] if rows else "固定锚点", } def rotation_history(self, trade_date: str, limit: int = 9) -> dict[str, Any]: normalized_date = normalize_date(trade_date) # 板块轮动固定展示最近 9 个交易日,按由近到远排列。 limit = 9 snapshots = self.database.list_snapshot_payloads(normalized_date, 240) by_trade_date: dict[str, dict[str, Any]] = {} for snapshot in snapshots: meta = snapshot.get("meta") or {} actual_date = str(meta.get("trade_date") or snapshot.get("_snapshot_date") or "") compact_date = actual_date.replace("-", "") if len(compact_date) == 8: by_trade_date[compact_date] = snapshot sentiment_dates = { str(row.get("trade_date") or "").replace("-", "") for row in latest_contiguous_history(build_sentiment_history(snapshots)) } ordered_dates = sorted( date_key for date_key in by_trade_date if not sentiment_dates or date_key in sentiment_dates )[-limit:][::-1] rows = [] for date_key in ordered_dates: snapshot = by_trade_date[date_key] sector_context = { str(item.get("name") or ""): item for item in snapshot.get("sectors") or [] } sectors = [] for item in (snapshot.get("sector_rotation") or [])[:12]: name = str(item.get("name") or "").strip() context = sector_context.get(name, {}) sectors.append( { "name": name, "rank": int(item.get("rank") or len(sectors) + 1), "trend": item.get("trend") or "持平", "count": int(item.get("count") or 0), "strength": float(item.get("strength") or context.get("strength") or 0), "change": float(context.get("change") or 0), "leader": item.get("leader") or context.get("leader") or "--", } ) rows.append( { "trade_date": f"{date_key[:4]}-{date_key[4:6]}-{date_key[6:]}", "sectors": sectors, } ) return { "trade_date": rows[0]["trade_date"] if rows else normalized_date, "available_days": len(ordered_dates), "requested_days": limit, "rows": rows, } def rotation_sector_members(self, trade_date: str, sector_name: str) -> dict[str, Any]: normalized_date = normalize_date(trade_date) sector_name = validate_text(sector_name, "板块名称", 60, required=True) dashboard = self.get_dashboard(normalized_date) actual_date = normalize_date( str((dashboard.get("meta") or {}).get("trade_date") or normalized_date) ) cache_key = f"{actual_date}:{sector_name}" cached = self.database.get_data_snapshot("rotation_sector_members_v1", cache_key) if cached: cached["meta"] = {**(cached.get("meta") or {}), "cached": True} return cached if not self.configured: raise ValueError("板块成分数据暂不可用。") representative = next( ( item for item in dashboard.get("limits") or [] if str(item.get("sector") or "").strip() == sector_name ), None, ) if not representative: raise ValueError("未找到该板块的代表股票,暂时无法核验成分股。") raw_code = str(representative.get("ts_code") or representative.get("code") or "") if "." in raw_code: ts_code = raw_code elif raw_code.startswith(("4", "8", "92")): ts_code = f"{raw_code}.BJ" elif raw_code.startswith(("6", "68", "90")): ts_code = f"{raw_code}.SH" else: ts_code = f"{raw_code}.SZ" client = self._tushare_client() try: industry = client.sw_stock_industry(ts_code, actual_date) sector_code = str(industry.get("l2_code") or "") members = client.sw_sector_members(sector_code, actual_date) except TushareError as exc: raise ValueError(f"该板块成分股暂不可用:{exc}") from exc daily_rows = self.database.daily_bars_for_date(actual_date) if len(daily_rows) < 1000: try: daily_rows = client.query( "daily", {"trade_date": actual_date}, "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount", ) if daily_rows: self.database.upsert_daily_bars(daily_rows) except TushareError: daily_rows = self.database.daily_bars_for_date(actual_date) daily_map = {str(item.get("ts_code") or ""): item for item in daily_rows} rows = [] for member in members: member_code = str(member.get("ts_code") or "") quote = daily_map.get(member_code) or {} rows.append( { "code": member_code.split(".")[0], "ts_code": member_code, "name": str(member.get("name") or "--"), "change": quote.get("pct_chg"), "open": quote.get("open"), "close": quote.get("close"), "amount_billion": ( round(float(quote.get("amount") or 0) / 100000, 2) if quote else None ), "quoted": bool(quote), } ) rows.sort( key=lambda item: ( bool(item.get("quoted")), float(item.get("change") or -999), float(item.get("amount_billion") or 0), ), reverse=True, ) result = { "meta": { "trade_date": self._display_compact_date(actual_date), "sector_name": str(industry.get("l2_name") or sector_name), "sector_code": sector_code, "member_count": len(rows), "quoted_count": sum(bool(item.get("quoted")) for item in rows), "cached": False, }, "rows": rows, } self.database.save_data_snapshot( "rotation_sector_members_v1", cache_key, "tushare", result ) return result def status(self) -> dict[str, Any]: llm_access = self.llm_access_status() return { "configured": self.configured, "mode": "tushare" if self.configured else "unavailable", "llm_configured": self.llm_configured, "llm_model": self.llm_primary_model if self.llm_configured else "", "llm_fallback_configured": self.llm_fallback_configured, "llm_fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "", "llm_access": llm_access, "birth_profile_configured": bool(self.stored_birth_profile()), "birth_profile": self.stored_birth_profile(), **self.database.status(), } def realtime_aggregate_health(self, sector: str = "") -> dict[str, Any]: sector = validate_text(sector, "板块名称", 50) return self.realtime_aggregator.health_snapshot(sector) def _market_insights(self) -> MarketInsightsService: if not self.configured: raise ValueError("行情数据尚未配置。") return MarketInsightsService( self.database, self._tushare_client(), ifind=self.ifind, ) def auction_center(self, trade_date: str, force: bool = False) -> dict[str, Any]: return self._market_insights().auction_center( normalize_date(trade_date), force, self.current_user_id ) def theme_library(self, trade_date: str, force: bool = False) -> dict[str, Any]: return self._market_insights().theme_library(normalize_date(trade_date), force) def theme_detail(self, code: str, trade_date: str) -> dict[str, Any]: return self._market_insights().theme_detail(code, normalize_date(trade_date)) def popularity(self, trade_date: str, force: bool = False) -> dict[str, Any]: return self._market_insights().popularity(normalize_date(trade_date), force) @staticmethod def _ifind_field(row: dict[str, Any], tokens: tuple[str, ...]) -> Any: for key, value in row.items(): label = str(key or "") if any(token.casefold() == label.casefold() for token in tokens): return value for key, value in row.items(): label = str(key or "") if any(token in label for token in tokens): return value return None @classmethod def _ifind_row_code(cls, row: dict[str, Any]) -> str: value = cls._ifind_field(row, ("股票代码", "证券代码", "代码", "thscode")) match = re.search(r"(? 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() if __name__ == "__main__": main()