Files
xiaobaifupan/app/backend/application.py
T

4334 lines
192 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import copy
import json
import re
import secrets
import threading
import time
from datetime import date, datetime, time as dt_time, timedelta, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler
from typing import Any
from urllib.parse import parse_qs, unquote, urlparse
from assistant_agent import ReviewAssistantError, stream_review_assistant
from api_access import ROUTES
from backend.bootstrap.container import build_application_container
from backend.bootstrap.settings import load_runtime_settings
from backend.http import HttpTransportMixin
from backend.llm import LLMGateway, LLMGatewayError
from backend.features.market import ChartDataError, MarketServiceMixin
from backend.bootstrap.config import (
DATA_DIR,
MENTOR_SKILLS_DIR,
PRIVATE_MENTOR_SKILLS_DIR,
TOKEN_PATTERN,
normalize_date,
tushare_code,
validate_stock_code,
validate_text,
)
from database import ReviewDatabase
from heaven_agent import HeavenAgentError, interpret_heaven
from heaven_engine import (
_market_line_scores,
_score_to_line,
build_five_phase_field,
build_market_hexagram,
build_personal_field,
hexagram_from_lines,
)
from backend.data.providers.ifind_client import IfindError
from llm_strategy import LLMCompilerError, compile_strategy_with_llm, test_llm_connection
from mentor_agent import MentorAgentError, stream_with_mentor
from market_insights import MarketInsightsService
from screener import (
FACTOR_FIELDS,
FACTOR_GROUPS,
REGIMES,
FactorDataService,
compile_local_strategy,
)
from backend.features.accounts.http import AccountHttpMixin
from backend.features.accounts.security import SecretVault
from backend.features.accounts.service import AccountService
from backend.features.pools import PoolServiceMixin
from backend.features.sentiment import SentimentServiceMixin
from backend.features.sentiment.engine import (
build_sentiment_history,
latest_contiguous_history,
)
from backend.features.system import SystemHttpMixin
from backend.data.providers.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",
}
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(MarketServiceMixin, SentimentServiceMixin, PoolServiceMixin):
def __init__(self) -> None:
runtime = load_runtime_settings()
self.vault = SecretVault(runtime.encryption_key)
self.database = ReviewDatabase(DATA_DIR / "review.db")
self.sync_lock = threading.Lock()
self.auth_lock = threading.Lock()
self.system_lock = threading.Lock()
self.auto_screener_lock = threading.Lock()
self._auto_screener_last_attempt: dict[str, datetime] = {}
self._ifind_event_lock = threading.Lock()
self._request_context = threading.local()
self.accounts = AccountService(
database=self.database,
vault=self.vault,
current_user_supplier=lambda: self.current_user_id,
access_supplier=lambda: getattr(self._request_context, "access", {}),
bind_user=self.bind_user,
personal_field_builder=build_personal_field,
auth_lock=self.auth_lock,
)
self._system_credentials = self._load_system_credentials(runtime.initial_credentials)
self.container = build_application_container(
self.database,
self._system_credentials,
MENTOR_SKILLS_DIR,
PRIVATE_MENTOR_SKILLS_DIR,
lambda: self.token,
)
self.data_gateway = self.container.data_gateway
self.ifind = self.container.ifind
self.screener = self.container.screener
self.strategy_tracking = self.container.strategy_tracking
self.alert_service = self.container.alert_service
self.trade_journal = self.container.trade_journal
self.mentor_skills = self.container.mentor_skills
self.realtime_aggregator = self.container.realtime_aggregator
self.chart_data = self.container.chart_data
self.jobs = self.container.jobs
self.llm_gateway = LLMGateway(
database=self.database,
user_id_supplier=lambda: self.current_user_id,
membership_supplier=self.membership,
settings_supplier=lambda: self._system_credentials,
profile_supplier=self._resolved_llm_profile,
)
self.screener.ensure_builtin_strategies()
self._background_stop = threading.Event()
self._background_thread = self.jobs.start_scheduler(
self._background_refresh_tick,
self._background_stop,
interval_seconds=5,
initial_delay_seconds=3,
)
def _load_system_credentials(self, environment: dict[str, str]) -> dict[str, Any]:
encrypted = self.database.get_system_setting("credentials")
current = self.vault.decrypt_json(encrypted) if encrypted else {}
changed = False
first_user_id = self.database.first_user_id()
first_personal: dict[str, Any] = {}
if first_user_id:
first_encrypted = self.database.get_user_credentials(first_user_id)
first_personal = self.vault.decrypt_json(first_encrypted) if first_encrypted else {}
defaults = {
"tushare_token": environment.get("tushare_token") or first_personal.get("tushare_token") or "",
"ifind_refresh_token": environment.get("ifind_refresh_token") or "",
"ifind_access_token": environment.get("ifind_access_token") or "",
"platform_llm_primary_api_key": environment.get("platform_llm_primary_api_key") or first_personal.get("llm_primary_api_key") or "",
"platform_llm_primary_base_url": environment.get("platform_llm_primary_base_url") or first_personal.get("llm_primary_base_url") or "https://api.openai.com/v1",
"platform_llm_primary_model": environment.get("platform_llm_primary_model") or first_personal.get("llm_primary_model") or "",
"platform_llm_fallback_api_key": environment.get("platform_llm_fallback_api_key") or first_personal.get("llm_fallback_api_key") or "",
"platform_llm_fallback_base_url": environment.get("platform_llm_fallback_base_url") or first_personal.get("llm_fallback_base_url") or "",
"platform_llm_fallback_model": environment.get("platform_llm_fallback_model") or first_personal.get("llm_fallback_model") or "",
"member_daily_limit": 50,
"background_refresh_enabled": True,
}
for key, value in defaults.items():
if key not in current:
current[key] = value
changed = True
if not isinstance(current.get("llm_models"), list):
migrated_models: list[dict[str, str]] = []
for role, label in (("primary", "原主模型"), ("fallback", "原辅助模型")):
profile = {
"api_key": str(current.get(f"platform_llm_{role}_api_key") or ""),
"base_url": str(current.get(f"platform_llm_{role}_base_url") or ""),
"model": str(current.get(f"platform_llm_{role}_model") or ""),
}
if profile["api_key"] or profile["model"]:
model_id = f"migrated-{role}"
migrated_models.append(
{"id": model_id, "name": label, **profile}
)
current[f"{role}_model_id"] = model_id
current["llm_models"] = migrated_models
current.setdefault("primary_model_id", "")
current.setdefault("fallback_model_id", "")
changed = True
if changed or not encrypted:
self.database.save_system_setting("credentials", self.vault.encrypt_json(current))
for row in self.database.list_user_credentials():
personal = self.vault.decrypt_json(str(row.get("encrypted_payload") or ""))
if "tushare_token" in personal:
personal.pop("tushare_token", None)
self.database.save_user_credentials(
int(row["user_id"]), self.vault.encrypt_json(personal)
)
return current
def _save_system_credentials(self, credentials: dict[str, Any]) -> None:
with self.system_lock:
self.database.save_system_setting("credentials", self.vault.encrypt_json(credentials))
self._system_credentials = dict(credentials)
if hasattr(self, "ifind"):
self.ifind.set_credentials(
str(credentials.get("ifind_refresh_token") or ""),
str(credentials.get("ifind_access_token") or ""),
)
@property
def configured(self) -> bool:
return bool(self.token)
def bind_user(self, user_id: int) -> None:
self._request_context.user_id = int(user_id)
encrypted = self.database.get_user_credentials(int(user_id))
self._request_context.credentials = self.vault.decrypt_json(encrypted) if encrypted else {}
self._request_context.access = self.database.user_access(int(user_id)) or {}
@property
def current_user_id(self) -> int:
user_id = getattr(self._request_context, "user_id", 0)
if not user_id:
raise ValueError("当前请求尚未绑定账号。")
return int(user_id)
def _credentials(self) -> dict[str, str]:
credentials = getattr(self._request_context, "credentials", {})
return {
"llm_primary_api_key": str(credentials.get("llm_primary_api_key") or ""),
"llm_primary_base_url": str(
credentials.get("llm_primary_base_url") or "https://api.openai.com/v1"
),
"llm_primary_model": str(credentials.get("llm_primary_model") or ""),
"llm_fallback_api_key": str(credentials.get("llm_fallback_api_key") or ""),
"llm_fallback_base_url": str(credentials.get("llm_fallback_base_url") or ""),
"llm_fallback_model": str(credentials.get("llm_fallback_model") or ""),
}
def _save_credentials(self, credentials: dict[str, str]) -> None:
self.database.save_user_credentials(
self.current_user_id,
self.vault.encrypt_json(credentials),
)
self._request_context.credentials = dict(credentials)
@property
def token(self) -> str:
return str(self._system_credentials.get("tushare_token") or "")
def _personal_llm_profile(self) -> dict[str, Any]:
credentials = self._credentials()
return {
"source": "personal",
"primary": {
"api_key": credentials["llm_primary_api_key"],
"base_url": credentials["llm_primary_base_url"],
"model": credentials["llm_primary_model"],
},
"fallback": {
"api_key": credentials["llm_fallback_api_key"],
"base_url": credentials["llm_fallback_base_url"],
"model": credentials["llm_fallback_model"],
},
}
def _platform_llm_profile(self) -> dict[str, Any]:
models = {
str(item.get("id") or ""): item
for item in self._system_credentials.get("llm_models") or []
if isinstance(item, dict) and item.get("id")
}
def selected(role: str) -> dict[str, str]:
item = models.get(str(self._system_credentials.get(f"{role}_model_id") or ""), {})
return {
"id": str(item.get("id") or ""),
"name": str(item.get("name") or ""),
"api_key": str(item.get("api_key") or ""),
"base_url": str(item.get("base_url") or ""),
"model": str(item.get("model") or ""),
}
return {
"source": "platform",
"primary": selected("primary"),
"fallback": selected("fallback"),
}
@staticmethod
def _profile_configured(profile: dict[str, str]) -> bool:
return bool(profile.get("api_key") and profile.get("base_url") and profile.get("model"))
def membership(self) -> dict[str, Any]:
return self.accounts.membership()
def _resolved_llm_profile(self) -> dict[str, Any]:
platform = self._platform_llm_profile()
platform_ready = self.membership()["active"] and self._profile_configured(platform["primary"])
if platform_ready:
return platform
return {"source": "none", "primary": {}, "fallback": {}}
@property
def llm_primary_api_key(self) -> str:
return str(self._resolved_llm_profile()["primary"].get("api_key") or "")
@property
def llm_primary_base_url(self) -> str:
return str(self._resolved_llm_profile()["primary"].get("base_url") or "")
@property
def llm_primary_model(self) -> str:
return str(self._resolved_llm_profile()["primary"].get("model") or "")
@property
def llm_fallback_api_key(self) -> str:
return str(self._resolved_llm_profile()["fallback"].get("api_key") or "")
@property
def llm_fallback_base_url(self) -> str:
return str(self._resolved_llm_profile()["fallback"].get("base_url") or "")
@property
def llm_fallback_model(self) -> str:
return str(self._resolved_llm_profile()["fallback"].get("model") or "")
@property
def llm_source(self) -> str:
return str(self._resolved_llm_profile().get("source") or "none")
@property
def llm_configured(self) -> bool:
return bool(self.llm_primary_api_key and self.llm_primary_model)
@property
def llm_fallback_configured(self) -> bool:
return bool(
self.llm_fallback_api_key
and self.llm_fallback_base_url
and self.llm_fallback_model
)
def save_llm_settings(
self,
primary: dict[str, Any],
fallback: dict[str, Any],
fallback_enabled: bool,
) -> None:
personal = self._personal_llm_profile()
primary_profile = self._validate_llm_profile(
primary,
personal["primary"],
required=True,
label="主模型",
)
if fallback_enabled:
fallback_profile = self._validate_llm_profile(
fallback,
personal["fallback"],
required=True,
label="辅助模型",
)
else:
fallback_profile = {"api_key": "", "base_url": "", "model": ""}
credentials = self._credentials()
credentials.update(
{
"llm_primary_api_key": primary_profile["api_key"],
"llm_primary_base_url": primary_profile["base_url"],
"llm_primary_model": primary_profile["model"],
"llm_fallback_api_key": fallback_profile["api_key"],
"llm_fallback_base_url": fallback_profile["base_url"],
"llm_fallback_model": fallback_profile["model"],
}
)
self._save_credentials(credentials)
def save_llm_mode(self, mode: str) -> None:
raise ValueError("LLM 算力由管理员统一配置,会员账号自动使用平台模型。")
def test_llm_profile(self, role: str, payload: dict[str, Any]) -> dict[str, Any]:
personal = self._personal_llm_profile()
if role == "primary":
current = personal["primary"]
label = "主模型"
elif role == "fallback":
current = personal["fallback"]
label = "辅助模型"
else:
raise ValueError("模型角色不支持。")
profile = self._validate_llm_profile(payload, current, required=True, label=label)
try:
return self.llm_gateway.probe(
profile,
lambda model: test_llm_connection(
model.api_key, model.base_url, model.model
),
)
except LLMCompilerError as exc:
raise ValueError(str(exc)) from exc
@staticmethod
def _validate_llm_profile(
payload: dict[str, Any],
current: dict[str, str],
required: bool,
label: str,
) -> dict[str, str]:
api_key = str(payload.get("api_key") or current.get("api_key") or "").strip()
base_url = str(payload.get("base_url") or current.get("base_url") or "").strip().rstrip("/")
model = str(payload.get("model") or current.get("model") or "").strip()
if not required and not any((api_key, base_url, model)):
return {"api_key": "", "base_url": "", "model": ""}
parsed = urlparse(base_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError(f"{label} Base URL 格式不正确。")
if not api_key or len(api_key) > 300:
raise ValueError(f"{label} API Key 不能为空或过长。")
if not model or len(model) > 100:
raise ValueError(f"{label}模型名称不能为空或过长。")
return {"api_key": api_key, "base_url": base_url, "model": model}
def llm_access_status(self) -> dict[str, Any]:
platform = self._platform_llm_profile()
membership = self.membership()
limit = max(1, int(self._system_credentials.get("member_daily_limit") or 50))
used = self._platform_usage_today() if membership["active"] else 0
resolved = self._resolved_llm_profile()
return {
"mode": "platform" if membership["active"] else "locked",
"resolved_source": resolved.get("source") or "none",
"resolved_model": str(resolved.get("primary", {}).get("model") or ""),
"platform_configured": self._profile_configured(platform["primary"]),
"membership": membership,
"daily_limit": limit,
"used_today": used,
"remaining_calls": None if membership["is_admin"] else max(0, limit - used),
}
def _platform_usage_today(self) -> int:
return self._platform_usage_today_for_user(self.current_user_id)
def _platform_usage_today_for_user(self, user_id: int) -> int:
now = datetime.now().astimezone()
start = now.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
return self.database.count_llm_usage_since(
user_id,
"platform",
start.isoformat(timespec="seconds"),
)
def system_status(self) -> dict[str, Any]:
platform = self._platform_llm_profile()
model_pool = []
for item in self._system_credentials.get("llm_models") or []:
if not isinstance(item, dict):
continue
profile = {
"api_key": str(item.get("api_key") or ""),
"base_url": str(item.get("base_url") or ""),
"model": str(item.get("model") or ""),
}
model_pool.append(
{
"id": str(item.get("id") or ""),
"name": str(item.get("name") or ""),
"base_url": profile["base_url"],
"model": profile["model"],
"configured": self._profile_configured(profile),
}
)
return {
"data": {
"configured": self.configured,
"ifind": self.ifind.status(),
"background_refresh_enabled": bool(
self._system_credentials.get("background_refresh_enabled", True)
),
**self.database.status(),
"jobs": self.jobs.repository.recent(12),
},
"llm": {
"primary_configured": self._profile_configured(platform["primary"]),
"fallback_configured": self._profile_configured(platform["fallback"]),
"models": model_pool,
"primary_model_id": str(self._system_credentials.get("primary_model_id") or ""),
"fallback_model_id": str(self._system_credentials.get("fallback_model_id") or ""),
},
"membership": {
"member_daily_limit": max(
1, int(self._system_credentials.get("member_daily_limit") or 50)
)
},
}
def save_system_settings(self, payload: dict[str, Any]) -> dict[str, Any]:
current = dict(self._system_credentials)
token = str(payload.get("tushare_token") or current.get("tushare_token") or "").strip()
if token and not TOKEN_PATTERN.fullmatch(token):
raise ValueError("Tushare Token 格式不正确。")
ifind_refresh_token = str(
payload.get("ifind_refresh_token")
or current.get("ifind_refresh_token")
or ""
).strip()
if ifind_refresh_token and (
len(ifind_refresh_token) > 2048
or any(character.isspace() for character in ifind_refresh_token)
):
raise ValueError("iFinD Refresh Token 格式不正确。")
existing_models = {
str(item.get("id") or ""): item
for item in current.get("llm_models") or []
if isinstance(item, dict) and item.get("id")
}
raw_models = payload.get("models")
models: list[dict[str, str]] = []
if raw_models is not None:
if not isinstance(raw_models, list) or len(raw_models) > 20:
raise ValueError("模型池格式不正确,最多可保存 20 个模型。")
seen_ids: set[str] = set()
seen_names: set[str] = set()
for index, raw in enumerate(raw_models, start=1):
if not isinstance(raw, dict):
raise ValueError("模型池条目格式不正确。")
model_id = str(raw.get("id") or f"model-{secrets.token_hex(6)}").strip()
if not re.fullmatch(r"[A-Za-z0-9_-]{3,80}", model_id) or model_id in seen_ids:
raise ValueError("模型 ID 不正确或重复。")
name = validate_text(raw.get("name"), f"模型 {index} 名称", 50, required=True)
normalized_name = name.casefold()
if normalized_name in seen_names:
raise ValueError("模型名称不能重复。")
profile = self._validate_llm_profile(
raw,
existing_models.get(model_id) or {},
required=True,
label=name,
)
models.append({"id": model_id, "name": name, **profile})
seen_ids.add(model_id)
seen_names.add(normalized_name)
else:
models = [dict(item) for item in existing_models.values()]
model_ids = {item["id"] for item in models}
primary_model_id = str(
payload.get("primary_model_id", current.get("primary_model_id") or "") or ""
).strip()
fallback_model_id = str(
payload.get("fallback_model_id", current.get("fallback_model_id") or "") or ""
).strip()
if models and primary_model_id not in model_ids:
raise ValueError("请从模型池选择主模型。")
if not models:
primary_model_id = ""
fallback_model_id = ""
if fallback_model_id and fallback_model_id not in model_ids:
raise ValueError("辅助模型不在模型池中。")
if fallback_model_id and fallback_model_id == primary_model_id:
raise ValueError("主模型与辅助模型不能相同。")
try:
daily_limit = max(
1,
min(
1000,
int(payload.get("member_daily_limit", current.get("member_daily_limit") or 50)),
),
)
except (TypeError, ValueError) as exc:
raise ValueError("会员每日额度应为 1 至 1000。") from exc
current.update(
{
"tushare_token": token,
"ifind_refresh_token": ifind_refresh_token,
"llm_models": models,
"primary_model_id": primary_model_id,
"fallback_model_id": fallback_model_id,
"member_daily_limit": daily_limit,
"background_refresh_enabled": bool(
payload.get(
"background_refresh_enabled",
current.get("background_refresh_enabled", True),
)
),
}
)
self._save_system_credentials(current)
return self.system_status()
def test_system_llm_profile(self, model_id: str, payload: dict[str, Any]) -> dict[str, Any]:
current = next(
(
item
for item in self._system_credentials.get("llm_models") or []
if str(item.get("id") or "") == model_id
),
{},
)
label = validate_text(payload.get("name") or current.get("name"), "模型名称", 50, required=True)
profile = self._validate_llm_profile(
payload, current, required=True, label=label
)
try:
return self.llm_gateway.probe(
profile,
lambda model: test_llm_connection(
model.api_key, model.base_url, model.model
),
)
except LLMCompilerError as exc:
raise ValueError(str(exc)) from exc
def admin_users(self) -> list[dict[str, Any]]:
return self.accounts.admin_users(self._platform_usage_today_for_user)
def update_membership(self, payload: dict[str, Any]) -> None:
self.accounts.update_membership(payload)
def request_background_sync(self, trade_date: str) -> bool:
normalized = normalize_date(trade_date)
key = f"manual:{normalized}:{time.time_ns()}"
return self.jobs.submit(
"market.refresh",
key,
lambda: self.sync_dashboard(normalized),
{"trade_date": normalized, "trigger": "administrator"},
)
def _background_refresh_tick(self) -> None:
if not (
self.configured
and self._system_credentials.get("background_refresh_enabled", True)
):
return
today = date.today().strftime("%Y%m%d")
snapshot = self.database.get_snapshot(today) or {}
if self._realtime_snapshot_due(today, snapshot):
bucket = int(time.time() // 5)
self.jobs.submit(
"market.refresh",
f"realtime:{today}:{bucket}",
lambda: self.sync_dashboard(today),
{"trade_date": today, "trigger": "realtime-poll"},
)
self._schedule_automatic_screeners(today, snapshot)
def register_account(self, username: str, password: str) -> dict[str, Any]:
return self.accounts.register(username, password)
def login_account(self, username: str, password: str) -> dict[str, Any]:
return self.accounts.login(username, password)
def change_password(self, current_password: str, new_password: str) -> None:
self.accounts.change_password(current_password, new_password)
def create_account_session(self, user: dict[str, Any]) -> dict[str, Any]:
return self.accounts.create_session(user)
@staticmethod
def _validate_account_input(username: str, password: str) -> None:
AccountService.validate_input(username, password)
def save_birth_profile(self, payload: dict[str, Any]) -> dict[str, Any]:
return self.accounts.save_birth_profile(payload)
def stored_birth_profile(self) -> dict[str, str] | None:
return self.accounts.stored_birth_profile()
def account_personal_field(
self,
current_date: str,
current_field: dict[str, Any],
public: bool = False,
) -> dict[str, Any] | None:
return self.accounts.personal_field(current_date, current_field, public)
@staticmethod
def _public_personal_profile(personal: dict[str, Any]) -> dict[str, Any]:
return AccountService.public_personal_profile(personal)
def 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 _market_insights(self) -> MarketInsightsService:
if not self.configured:
raise ValueError("行情数据尚未配置。")
return MarketInsightsService(
self.database,
self._tushare_client(),
ifind=self.ifind,
)
def auction_center(self, trade_date: str, force: bool = False) -> dict[str, Any]:
return self._market_insights().auction_center(
normalize_date(trade_date), force, self.current_user_id
)
def theme_library(self, trade_date: str, force: bool = False) -> dict[str, Any]:
return self._market_insights().theme_library(normalize_date(trade_date), force)
def theme_detail(self, code: str, trade_date: str) -> dict[str, Any]:
return self._market_insights().theme_detail(code, normalize_date(trade_date))
def popularity(self, trade_date: str, force: bool = False) -> dict[str, Any]:
return self._market_insights().popularity(normalize_date(trade_date), force)
@staticmethod
def _ifind_field(row: dict[str, Any], tokens: tuple[str, ...]) -> Any:
for key, value in row.items():
label = str(key or "")
if any(token.casefold() == label.casefold() for token in tokens):
return value
for key, value in row.items():
label = str(key or "")
if any(token in label for token in tokens):
return value
return None
@classmethod
def _ifind_row_code(cls, row: dict[str, Any]) -> str:
value = cls._ifind_field(row, ("股票代码", "证券代码", "代码", "thscode"))
match = re.search(r"(?<!\d)(\d{6})(?!\d)", str(value or ""))
if match:
return match.group(1)
for value in row.values():
match = re.search(r"(?<!\d)(\d{6})\.(?:SH|SZ|BJ)(?![A-Z])", str(value or ""), re.I)
if match:
return match.group(1)
return ""
@staticmethod
def _strategy_missing_data(
strategy: dict[str, Any], factor_dates: list[str], factor_health: dict[str, Any]
) -> list[str]:
formula = strategy.get("formula") or {}
meta = formula.get("meta") or {}
used_fields = {
str(item.get("field") or "")
for item in list(formula.get("filters") or []) + list(formula.get("score") or [])
}
valuation_fields = {"pe_ttm", "pb", "ps_ttm", "dividend_yield_ttm", "total_mv_billion"}
fundamental_fields = {"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "ocf_to_opincome"}
auction_fields = {"auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio"}
missing = []
required_history = max(21, min(260, int(meta.get("history_days") or 21)))
if len(factor_dates) < required_history:
missing.append(f"历史行情(需{required_history}日)")
if used_fields & valuation_fields and not factor_health["valuation"]:
missing.append("估值数据")
if used_fields & fundamental_fields and not factor_health["fundamental"]:
missing.append("财务质量")
if meta.get("requires_valuation") and not factor_health["valuation"]:
missing.append("估值数据")
if meta.get("requires_fundamental") and not factor_health["fundamental"]:
missing.append("财务质量")
if "dividend_years" in used_fields and not factor_health["dividend_history"]:
missing.append("历年分红")
if used_fields & auction_fields and not factor_health["auction"]:
missing.append("竞价数据")
if meta.get("requires_benchmark") and not factor_health.get("benchmark"):
missing.append("沪深300基准")
if meta.get("requires_moneyflow_history") and not factor_health.get("moneyflow_history"):
missing.append("近5日资金流")
if meta.get("requires_earnings_events") and not factor_health.get("earnings_events"):
missing.append("业绩预告与快报")
if meta.get("requires_popularity") and not factor_health.get("popularity"):
missing.append("当日人气榜")
if meta.get("requires_institutions") and not factor_health.get("institutions"):
missing.append("龙虎榜机构席位")
return list(dict.fromkeys(missing))
def screener_setup(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
regime = self.screener.detect_regime(normalized_date)
factor_dates = self.database.factor_dates(normalized_date, 300)
auction_dates = self.database.auction_factor_dates(normalized_date, 100)
factor_health = self.screener.factor_health(normalized_date)
strategies = self.database.list_screener_strategies(self.current_user_id)
for strategy in strategies:
missing = self._strategy_missing_data(strategy, factor_dates, factor_health)
strategy["data_ready"] = not missing
strategy["missing_data"] = missing
automatic_results = self.database.screener_runs_for_date(0, normalized_date)
personal_results = self.database.screener_runs_for_date(
self.current_user_id, normalized_date
)
recent_results = [
*[item for item in automatic_results if item.get("meta", {}).get("mode") in {"smart", "curated"}],
*[item for item in personal_results if item.get("meta", {}).get("mode") == "quant"],
]
latest_results: dict[str, dict[str, Any]] = {}
for result in reversed(recent_results):
mode = str(result.get("meta", {}).get("mode") or "smart")
latest_results[mode] = result
automatic_status = self.database.get_data_snapshot(
"screener_auto_v1", normalized_date
) or {}
return {
"trade_date": normalized_date,
"regime": regime,
"regimes": [{"id": key, "label": value} for key, value in REGIMES.items()],
"strategies": strategies,
"factor_fields": [{"id": key, "label": value} for key, value in FACTOR_FIELDS.items()],
"factor_groups": [
{
"name": name,
"fields": [{"id": field, "label": FACTOR_FIELDS[field]} for field in fields],
}
for name, fields in FACTOR_GROUPS.items()
],
"operators": [">", ">=", "<", "<=", "==", "between"],
"factor_data": {
"date_count": len(factor_dates),
"start_date": factor_dates[0] if factor_dates else "",
"end_date": factor_dates[-1] if factor_dates else "",
"ready": len(factor_dates) >= 21,
"auction_date_count": len(auction_dates),
"auction_ready": bool(auction_dates and auction_dates[-1] == factor_dates[-1]) if factor_dates else False,
"health": factor_health,
},
"llm": {
"configured": self.llm_configured,
"model": self.llm_primary_model if self.llm_configured else "",
"fallback_configured": self.llm_fallback_configured,
"fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
},
"latest_results": latest_results,
"recent_results": recent_results,
"automatic_status": automatic_status,
# Kept during the client transition for compatibility with older frontends.
"latest_result": latest_results.get("smart"),
}
def screener_tracking(self, limit: int = 12) -> dict[str, Any]:
return self.strategy_tracking.list_tracking(self.current_user_id, limit)
def add_screener_tracking(self, payload: dict[str, Any]) -> dict[str, Any]:
try:
run_id = int(payload.get("run_id") or 0)
except (TypeError, ValueError) as exc:
raise ValueError("选股批次无效。") from exc
code = str(payload.get("code") or "").strip()
if run_id <= 0 or not re.fullmatch(r"\d{6}", code):
raise ValueError("选股批次或股票代码无效。")
return self.strategy_tracking.add_candidate(self.current_user_id, run_id, code)
def remove_screener_tracking(self, track_id: int) -> dict[str, Any]:
return self.strategy_tracking.remove_candidate(self.current_user_id, track_id)
def refresh_screener_tracking(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
notice = ""
if self.configured:
try:
FactorDataService(self.database, self._tushare_client()).sync(
normalized_date, 15
)
except TushareError:
notice = "最新日线暂未补齐,已按现有数据更新跟踪。"
else:
notice = "公共行情尚未配置,已按现有数据更新跟踪。"
return {
"tracking": self.screener_tracking(),
"notice": notice,
}
def alert_center(self, status: str = "all", as_of: str = "") -> dict[str, Any]:
tracking = self.strategy_tracking.list_tracking(self.current_user_id, 12)
self.alert_service.sync_strategy_tracking(self.current_user_id, tracking)
return self.alert_service.list_alerts(
self.current_user_id, status, as_of
)
def create_alert(self, payload: dict[str, Any]) -> dict[str, Any]:
alert_id = self.alert_service.create_manual(self.current_user_id, payload)
return {"id": alert_id, **self.alert_center()}
def mark_alert_read(self, alert_id: int) -> dict[str, Any]:
self.alert_service.mark_read(self.current_user_id, alert_id)
return self.alert_center()
def mark_all_alerts_read(self, as_of: str = "") -> dict[str, Any]:
compact_date = self.alert_service.calendar_date(as_of or date.today().isoformat())
self.alert_service.mark_all_read(self.current_user_id, compact_date)
return self.alert_center(as_of=compact_date)
def delete_alert(self, alert_id: int) -> dict[str, Any]:
deleted = self.alert_service.delete(self.current_user_id, alert_id)
return {"deleted": deleted, **self.alert_center()}
def trade_entries(
self, start_date: str = "", end_date: str = "", code: str = ""
) -> dict[str, Any]:
return self.trade_journal.list_entries(
self.current_user_id, start_date, end_date, code
)
def review_watchlist(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
items = self.database.list_watchlist(self.current_user_id)
if not items:
return {"items": [], "trade_date": normalized_date}
resolved_date = normalized_date
if self.configured:
try:
client = self._tushare_client()
resolved_date, _ = client.resolve_trade_context(normalized_date)
history = self.database.watchlist_price_history(
[str(item["code"]) for item in items], resolved_date
)
missing_codes = [
str(item["code"]) for item in items
if len(history.get(str(item["code"])) or []) < 6
]
start_date = (
datetime.strptime(resolved_date, "%Y%m%d") - timedelta(days=24)
).strftime("%Y%m%d")
for code in missing_codes:
rows = client.query(
"daily",
{
"ts_code": tushare_code(code),
"start_date": start_date,
"end_date": resolved_date,
},
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
)
if rows:
self.database.upsert_daily_bars(rows)
if missing_codes:
history = self.database.watchlist_price_history(
[str(item["code"]) for item in items], resolved_date
)
except (TushareError, ValueError):
history = self.database.watchlist_price_history(
[str(item["code"]) for item in items], resolved_date
)
else:
history = self.database.watchlist_price_history(
[str(item["code"]) for item in items], resolved_date
)
auction_scores: dict[str, Any] = {}
try:
auction = self.auction_center(normalized_date, False)
auction_scores = {
str(row.get("code") or ""): row.get("attention_score")
for row in (auction.get("watchlist_rows") or [])
if row.get("available", True)
}
except (TushareError, ValueError):
pass
enriched = []
for item in items:
code = str(item.get("code") or "")
bars = history.get(code) or []
latest = bars[-1] if bars else {}
close = float(latest.get("close") or 0)
base_close = float(bars[-6].get("close") or 0) if len(bars) >= 6 else 0
enriched.append(
{
**item,
"change": (
round(float(latest.get("pct_chg") or 0), 2) if latest else None
),
"return_5d": (
round((close / base_close - 1) * 100, 2)
if close > 0 and base_close > 0 else None
),
"attention_score": auction_scores.get(code),
"market_date": str(latest.get("trade_date") or ""),
}
)
return {"items": enriched, "trade_date": resolved_date}
def save_trade_entry(self, payload: dict[str, Any]) -> dict[str, Any]:
trade_id = self.trade_journal.save(self.current_user_id, payload)
return {"id": trade_id, **self.trade_entries()}
def delete_trade_entry(self, trade_id: int) -> dict[str, Any]:
deleted = self.trade_journal.delete(self.current_user_id, trade_id)
return {"deleted": deleted, **self.trade_entries()}
def assistant_messages(self) -> list[dict[str, Any]]:
return self.database.list_assistant_messages(self.current_user_id)
def clear_assistant_messages(self) -> int:
return self.database.delete_assistant_messages(self.current_user_id)
def assistant_stream(self, payload: dict[str, Any]):
question = validate_text(payload.get("question"), "问题", 2000, required=True)
trade_date = normalize_date(
str(payload.get("trade_date") or date.today().isoformat())
)
context = self._assistant_context(trade_date)
history = [
{"role": item["role"], "content": str(item["content"])[:4000]}
for item in self.assistant_messages()[-12:]
if item.get("role") in {"user", "assistant"}
]
def generate():
answer_parts: list[str] = []
events = self.llm_gateway.stream(
"assistant",
"review-assistant-v1",
lambda profile: stream_review_assistant(
context,
question,
history,
profile.api_key,
profile.base_url,
profile.model,
),
(ReviewAssistantError,),
)
for event in events:
if event.kind == "delta":
chunk = str(event.value or "")
answer_parts.append(chunk)
yield chunk
elif event.kind == "complete":
self.database.save_assistant_exchange(
self.current_user_id,
question,
"".join(answer_parts).strip(),
trade_date,
)
return generate()
def _assistant_context(self, trade_date: str) -> dict[str, Any]:
dashboard = self.get_dashboard(trade_date)
actual_date = normalize_date(
str((dashboard.get("meta") or {}).get("trade_date") or trade_date)
)
sentiment = self.sentiment_history(actual_date, 10)
tracking = self.strategy_tracking.list_tracking(self.current_user_id, 5)
alerts = self.alert_service.list_alerts(
self.current_user_id, "all", date.today().isoformat()
)
trades = self.trade_journal.list_entries(
self.current_user_id, end_date=actual_date
)
return {
"data_date": actual_date,
"market": {
"overview": dashboard.get("overview") or {},
"top_sectors": (dashboard.get("sectors") or [])[:8],
"limit_performance": dashboard.get("limit_performance") or {},
"sentiment_history": (sentiment.get("rows") or [])[-10:],
},
"personal": {
"watchlist": self.database.list_watchlist(self.current_user_id)[:30],
"review_notes": self.database.list_notes(
self.current_user_id, scope="daily"
)[:10],
"strategy_tracking": {
"summary": tracking.get("summary") or {},
"batches": (tracking.get("batches") or [])[:5],
},
"alerts": (alerts.get("items") or [])[:20],
"trade_summary": trades.get("summary") or {},
"trade_entries": (trades.get("items") or [])[:30],
},
}
def sync_screener_data(self, trade_date: str, lookback: int = 45) -> dict[str, Any]:
if not self.configured:
raise ValueError("请先配置 Tushare Token。")
normalized_date = normalize_date(trade_date)
lookback = max(25, min(260, int(lookback)))
with self.sync_lock:
return FactorDataService(self.database, self._tushare_client()).sync(
normalized_date, lookback
)
def _schedule_automatic_screeners(
self, trade_date: str, snapshot: dict[str, Any] | None = None
) -> bool:
normalized_date = normalize_date(trade_date)
now = datetime.now().astimezone()
if (
normalized_date != now.strftime("%Y%m%d")
or now.weekday() >= 5
or now.time().replace(tzinfo=None) < datetime.strptime("15:10", "%H:%M").time()
or self.auto_screener_lock.locked()
):
return False
snapshot = snapshot or self.database.get_snapshot(normalized_date) or {}
actual_date = str((snapshot.get("meta") or {}).get("trade_date") or "").replace("-", "")
if actual_date != normalized_date:
return False
marker = self.database.get_data_snapshot("screener_auto_v1", normalized_date) or {}
if (
marker.get("status") == "complete"
and int(marker.get("library_version") or 0) == SCREENER_LIBRARY_VERSION
):
return False
last_attempt = self._auto_screener_last_attempt.get(normalized_date)
if last_attempt and (now - last_attempt).total_seconds() < 600:
return False
self._auto_screener_last_attempt[normalized_date] = now
return self.jobs.submit(
"screener.automatic",
f"{normalized_date}:v{SCREENER_LIBRARY_VERSION}",
lambda: self.run_automatic_screeners(normalized_date),
{"trade_date": normalized_date, "trigger": "post-close"},
)
def run_automatic_screeners(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
with self.auto_screener_lock:
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
status: dict[str, Any] = {
"trade_date": normalized_date,
"library_version": SCREENER_LIBRARY_VERSION,
"status": "running",
"started_at": started_at,
"completed": [],
"skipped": [],
"failed": [],
}
self.database.save_data_snapshot(
"screener_auto_v1", normalized_date, "system", status
)
try:
factor_sync = FactorDataService(
self.database, self._tushare_client()
).sync(normalized_date, 260)
factor_dates = self.database.factor_dates(normalized_date, 300)
if not factor_dates or factor_dates[-1] != normalized_date:
raise ValueError("当日收盘行情尚未入库")
factor_health = self.screener.factor_health(normalized_date)
regime = self.screener.detect_regime(normalized_date)
regime_id = str(regime.get("id") or "repair")
strategies = self.database.list_screener_strategies(None)
jobs = automatic_screener_jobs(strategies, regime_id)
existing = {
(
str(item.get("meta", {}).get("mode") or "smart"),
str(item.get("meta", {}).get("strategy_name") or ""),
)
for item in self.database.screener_runs_for_date(0, normalized_date)
if int(item.get("meta", {}).get("library_version") or 0)
== SCREENER_LIBRARY_VERSION
}
required_history = max(
[
int((job["strategy"].get("formula", {}).get("meta", {}) or {}).get("history_days") or 80)
for job in jobs if job.get("strategy")
] or [80]
)
factors, actual_date = self.screener.build_factors(
normalized_date, history_days=required_history
)
if actual_date != normalized_date:
raise ValueError("当日因子尚未完成收盘定格")
for job in jobs:
strategy = job["strategy"]
mode = str(job["mode"])
name = str(strategy.get("name") or "未命名策略")
if (mode, name) in existing:
status["completed"].append({"mode": mode, "name": name, "cached": True})
continue
missing = self._strategy_missing_data(
strategy, factor_dates, factor_health
)
if missing:
status["skipped"].append(
{"mode": mode, "name": name, "reason": "、".join(missing)}
)
continue
try:
formula = copy.deepcopy(strategy.get("formula") or {})
formula.setdefault("meta", {})["library_version"] = (
SCREENER_LIBRARY_VERSION
)
result = self.screener.screen(
0,
normalized_date,
formula,
regime_id,
name,
False,
None,
mode,
factors,
actual_date,
)
status["completed"].append(
{
"mode": mode,
"name": name,
"candidate_count": len(result.get("candidates") or []),
}
)
except Exception as exc:
status["failed"].append(
{"mode": mode, "name": name, "reason": str(exc)}
)
status.update(
{
"status": "complete" if not status["failed"] else "partial",
"finished_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"factor_sync": factor_sync,
"regime": regime,
}
)
except Exception as exc:
status.update(
{
"status": "failed",
"finished_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"error": str(exc),
}
)
self.database.save_data_snapshot(
"screener_auto_v1", normalized_date, "system", status
)
return status
def compile_screener_strategy(self, prompt: str, regime: str) -> dict[str, Any]:
prompt = prompt.strip()
if not prompt or len(prompt) > 3000:
raise ValueError("策略描述应为 1 至 3000 个字符。")
if regime not in REGIMES:
raise ValueError("市场阶段不支持。")
notice = ""
source = self.llm_source
if source == "platform":
try:
gateway_result = self.llm_gateway.call(
"screener",
"strategy-compiler-v1",
lambda profile: compile_strategy_with_llm(
prompt,
regime,
profile.api_key,
profile.base_url,
profile.model,
),
(LLMCompilerError,),
)
compiled = gateway_result.value
if gateway_result.role == "fallback":
compiled["compiler"] = "llm_fallback"
notice = "智能策略生成服务已自动切换。"
except LLMGatewayError as exc:
if exc.code != "unavailable":
raise
compiled = compile_local_strategy(prompt, regime)
notice = "智能策略生成暂不可用,已使用本地模板。"
else:
compiled = compile_local_strategy(prompt, regime)
notice = "智能策略生成暂不可用,已使用本地模板。"
compiled["formula"] = self.screener.validate_formula(compiled["formula"])
compiled["notice"] = notice
return compiled
def save_screener_strategy(self, payload: dict[str, Any]) -> dict[str, Any]:
name = validate_text(payload.get("name"), "策略名称", 60, required=True)
description = validate_text(payload.get("description"), "策略说明", 1000)
regimes = payload.get("regimes") or []
if not isinstance(regimes, list) or not regimes or any(item not in REGIMES for item in regimes):
raise ValueError("策略适用阶段不正确。")
formula = self.screener.validate_formula(payload.get("formula") or {})
strategy_id = self.database.save_screener_strategy(
self.current_user_id, name, description, regimes, formula
)
return {
"id": strategy_id,
"strategies": self.database.list_screener_strategies(self.current_user_id),
}
def delete_screener_strategy(self, strategy_id: int) -> dict[str, Any]:
deleted = self.database.delete_screener_strategy(self.current_user_id, strategy_id)
return {
"deleted": deleted,
"strategies": self.database.list_screener_strategies(self.current_user_id),
}
def mentor_setup(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
mentors = [
skill.public()
for skill in self.mentor_skills.list_skills(
include_private=self.membership()["is_admin"]
)
]
if not mentors:
raise ValueError("游资skills 目录中没有可用的 SKILL.md。")
stored_preferences = self.database.list_mentor_preferences(self.current_user_id)
preferences = {item["mentor_id"]: item for item in stored_preferences}
for default_order, mentor in enumerate(mentors):
preference = preferences.get(str(mentor.get("id") or ""), {})
mentor["pinned"] = bool(preference.get("pinned"))
mentor["sort_order"] = int(preference.get("sort_order", 10000 + default_order))
mentors.sort(
key=lambda item: (
not bool(item.get("pinned")),
int(item.get("sort_order") or 0),
)
)
for sort_order, mentor in enumerate(mentors):
mentor["sort_order"] = sort_order
snapshot = self.database.get_snapshot(normalized_date)
actual_date = str((snapshot or {}).get("meta", {}).get("trade_date") or normalized_date)
return {
"trade_date": actual_date,
"mentors": mentors,
"preferences_configured": bool(stored_preferences),
"llm": {
"configured": self.llm_configured,
"model": self.llm_primary_model if self.llm_configured else "",
"fallback_configured": self.llm_fallback_configured,
"fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
},
}
def save_mentor_preferences(self, payload: dict[str, Any]) -> dict[str, Any]:
available_ids = [
skill.skill_id
for skill in self.mentor_skills.list_skills(
include_private=self.membership()["is_admin"]
)
]
available = set(available_ids)
raw_order = payload.get("order")
raw_pinned = payload.get("pinned")
if not isinstance(raw_order, list) or not isinstance(raw_pinned, list):
raise ValueError("问师排序格式不正确。")
ordered_ids: list[str] = []
for raw_id in raw_order:
mentor_id = validate_text(raw_id, "问师角色", 100, required=True)
if mentor_id not in available:
raise ValueError("问师排序中包含不可用的思维模型。")
if mentor_id not in ordered_ids:
ordered_ids.append(mentor_id)
ordered_ids.extend(mentor_id for mentor_id in available_ids if mentor_id not in ordered_ids)
pinned_ids = {
validate_text(raw_id, "问师角色", 100, required=True)
for raw_id in raw_pinned
}
if not pinned_ids.issubset(available):
raise ValueError("问师置顶中包含不可用的思维模型。")
self.database.save_mentor_preferences(
self.current_user_id, ordered_ids, pinned_ids
)
return {"saved": True}
def mentor_stream(self, payload: dict[str, Any]):
mentor_id = validate_text(payload.get("mentor_id"), "问师角色", 100, required=True)
question = validate_text(payload.get("question"), "问题", 2000, required=True)
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
history = self._validate_mentor_history(payload.get("history") or [])
skill = self.mentor_skills.get_skill(
mentor_id, include_private=self.membership()["is_admin"]
)
context = self._build_mentor_context(trade_date, question, skill)
def generate():
answer_parts: list[str] = []
events = self.llm_gateway.stream(
"mentor",
f"mentor-skill-v1:{skill.skill_id}",
lambda profile: stream_with_mentor(
skill,
context,
question,
history,
profile.api_key,
profile.base_url,
profile.model,
),
(MentorAgentError,),
)
for event in events:
if event.kind == "delta":
chunk = str(event.value or "")
answer_parts.append(chunk)
yield {"type": "delta", "content": chunk}
elif event.kind == "complete":
self.database.save_mentor_exchange(
self.current_user_id,
mentor_id,
trade_date,
question,
"".join(answer_parts).strip(),
context["data_trade_date"],
)
yield {
"type": "meta",
"data_trade_date": context["data_trade_date"],
"notice": "智能解读已自动切换可用服务。"
if event.role == "fallback"
else "",
}
return generate()
def mentor_messages(self, mentor_id: str, trade_date: str) -> list[dict[str, Any]]:
mentor_id = validate_text(mentor_id, "问师角色", 100, required=True)
trade_date = normalize_date(trade_date)
self.mentor_skills.get_skill(
mentor_id, include_private=self.membership()["is_admin"]
)
return self.database.list_mentor_messages(
self.current_user_id, mentor_id, trade_date
)
def clear_mentor_messages(self, mentor_id: str, trade_date: str) -> int:
mentor_id = validate_text(mentor_id, "问师角色", 100, required=True)
trade_date = normalize_date(trade_date)
self.mentor_skills.get_skill(
mentor_id, include_private=self.membership()["is_admin"]
)
return self.database.delete_mentor_messages(
self.current_user_id, mentor_id, trade_date
)
@staticmethod
def _heaven_manual_schema(market_mode: str) -> dict[str, dict[str, Any]]:
intraday = market_mode == "intraday"
fields = {
"stock_amount_percentile": {"line": 1, "label": "成交额全市场分位", "unit": "%", "min": 0, "max": 100},
"stock_turnover_rate": {"line": 1, "label": "个股换手率", "unit": "%", "min": 0, "max": 100},
"stock_turnover_relative": {"line": 1, "label": "相对市场换手", "unit": "倍", "min": 0, "max": 20},
"stock_volume_activity_ratio": {"line": 1, "label": "同进度量能", "unit": "倍", "min": 0, "max": 20},
"stock_seal_amount_million": {"line": 1, "label": "封单金额", "unit": "万元", "min": 0, "max": 100000000},
"stock_open_times": {"line": 1, "label": "开板次数", "unit": "次", "min": 0, "max": 100, "integer": True},
"stock_change": {"line": 2, "label": "个股涨跌幅", "unit": "%", "min": -100, "max": 100},
"stock_streak": {"line": 2, "label": "连板高度", "unit": "板", "min": 0, "max": 100, "integer": True},
"stock_status": {"line": 2, "label": "个股状态", "type": "select", "options": ["普通", "涨停", "炸板", "跌停"]},
"sector_name": {"line": [3, 4], "label": "申万二级行业", "type": "text", "max_length": 50},
"sector_up_count": {"line": 3, "label": "行业上涨家数", "unit": "家", "min": 0, "max": 10000, "integer": True},
"sector_down_count": {"line": 3, "label": "行业下跌家数", "unit": "家", "min": 0, "max": 10000, "integer": True},
"sector_coverage": {"line": 3, "label": "成分行情覆盖率", "unit": "%", "min": 0, "max": 100},
"sector_relative_turnover": {"line": 3, "label": "行业相对市场换手", "unit": "倍", "min": 0, "max": 20},
"sector_member_equal_change": {"line": 3, "label": "成分等权涨跌幅", "unit": "%", "min": -100, "max": 100},
"sector_change": {"line": 4, "label": "申万官方涨跌幅", "unit": "%", "min": -100, "max": 100},
"sector_leading_pct": {"line": [3, 4], "label": "行业领涨股涨跌幅", "unit": "%", "min": -100, "max": 100},
"market_sentiment_score": {"line": 5, "label": "市场情绪温度", "unit": "分", "min": 0, "max": 100},
"market_seal_rate": {"line": 5, "label": "封板率", "unit": "%", "min": 0, "max": 100},
"market_amount_billion": {"line": 5, "label": "两市成交额", "unit": "亿元", "min": 0, "max": 10000000},
"market_recent_average_amount_billion": {"line": 5, "label": "近期平均成交额", "unit": "亿元", "min": 0, "max": 10000000},
"market_up_count": {"line": 5, "label": "上涨家数", "unit": "家", "min": 0, "max": 10000, "integer": True},
"market_down_count": {"line": 5, "label": "下跌家数", "unit": "家", "min": 0, "max": 10000, "integer": True},
"market_limit_up_count": {"line": 5, "label": "涨停家数", "unit": "家", "min": 0, "max": 10000, "integer": True},
"market_limit_down_count": {"line": 5, "label": "跌停家数", "unit": "家", "min": 0, "max": 10000, "integer": True},
"index_sh_change": {"line": 6, "label": "上证指数涨跌幅", "unit": "%", "min": -20, "max": 20},
"index_sz_change": {"line": 6, "label": "深证成指涨跌幅", "unit": "%", "min": -20, "max": 20},
"index_cy_change": {"line": 6, "label": "创业板指涨跌幅", "unit": "%", "min": -20, "max": 20},
"note": {"line": [], "label": "补录说明", "type": "text", "max_length": 200},
}
if intraday:
for key in ("stock_seal_amount_million", "stock_open_times"):
fields.pop(key)
else:
for key in ("stock_turnover_relative", "stock_volume_activity_ratio", "sector_relative_turnover"):
fields.pop(key)
return fields
@classmethod
def _validate_heaven_manual_data(
cls, raw: Any, market_mode: str
) -> dict[str, Any]:
if raw in (None, ""):
return {}
if not isinstance(raw, dict):
raise ValueError("六爻补录数据格式不正确。")
schema = cls._heaven_manual_schema(market_mode)
unknown = set(raw) - set(schema)
if unknown:
raise ValueError(f"六爻补录包含未知字段:{next(iter(sorted(unknown)))}")
values: dict[str, Any] = {}
for key, value in raw.items():
if value is None or (isinstance(value, str) and not value.strip()):
continue
spec = schema[key]
if spec.get("type") == "text":
values[key] = validate_text(value, spec["label"], int(spec["max_length"]))
continue
if spec.get("type") == "select":
text = str(value).strip()
if text not in spec["options"]:
raise ValueError(f"{spec['label']}不在允许范围内。")
values[key] = text
continue
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{spec['label']}必须是数字。") from exc
if number < float(spec["min"]) or number > float(spec["max"]):
raise ValueError(
f"{spec['label']}应在 {spec['min']}{spec['max']} 之间。"
)
values[key] = int(number) if spec.get("integer") else number
return values
@staticmethod
def _apply_heaven_manual_data(
dashboard: dict[str, Any],
index_context: dict[str, Any],
sector: dict[str, Any] | None,
stock: dict[str, Any] | None,
manual_data: dict[str, Any],
market_mode: str,
trade_date: str,
stock_code: str,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
dashboard = copy.deepcopy(dashboard)
index_context = copy.deepcopy(index_context or {})
sector = copy.deepcopy(sector or {})
stock = copy.deepcopy(stock or {})
overview = dashboard.setdefault("overview", {})
stock_map = {
"stock_amount_percentile": "amount_percentile",
"stock_turnover_rate": "turnover_rate",
"stock_turnover_relative": "turnover_relative",
"stock_volume_activity_ratio": "volume_activity_ratio",
"stock_seal_amount_million": "seal_amount_million",
"stock_open_times": "open_times",
"stock_change": "change",
"stock_streak": "streak",
"stock_status": "status",
}
sector_map = {
"sector_name": "name",
"sector_up_count": "up_count",
"sector_down_count": "down_count",
"sector_coverage": "coverage",
"sector_relative_turnover": "relative_turnover",
"sector_member_equal_change": "member_equal_change",
"sector_change": "change",
"sector_leading_pct": "leading_pct",
}
overview_map = {
"market_sentiment_score": "sentiment_score",
"market_seal_rate": "seal_rate",
"market_amount_billion": "amount_billion",
"market_recent_average_amount_billion": "recent_average_amount_billion",
"market_up_count": "up_count",
"market_down_count": "down_count",
"market_limit_up_count": "limit_up_count",
"market_limit_down_count": "limit_down_count",
}
for manual_key, target in stock_map.items():
if manual_key in manual_data:
stock[target] = manual_data[manual_key]
for manual_key, target in sector_map.items():
if manual_key in manual_data:
sector[target] = manual_data[manual_key]
for manual_key, target in overview_map.items():
if manual_key in manual_data:
overview[target] = manual_data[manual_key]
if any(key.startswith("stock_") for key in manual_data):
stock.setdefault("code", stock_code)
stock.setdefault("name", stock_code or "--")
stock["_quantitative_mode"] = "intraday" if market_mode == "intraday" else "historical"
if market_mode == "intraday" and "stock_volume_activity_ratio" in manual_data:
stock["activity_source"] = "user_supplied"
if any(key.startswith("sector_") for key in manual_data):
sector["_quantitative_mode"] = "intraday" if market_mode == "intraday" else "historical"
sector.setdefault("taxonomy", "sw_l2")
index_keys = (
("index_sh_change", "000001.SH", "上证指数"),
("index_sz_change", "399001.SZ", "深证成指"),
("index_cy_change", "399006.SZ", "创业板指"),
)
rows = {str(row.get("ts_code") or row.get("code") or ""): dict(row) for row in index_context.get("indices") or []}
for manual_key, code, name in index_keys:
if manual_key not in manual_data:
continue
row = rows.get(code, {"ts_code": code, "name": name})
row.update({"pct_chg": manual_data[manual_key], "trade_date": trade_date})
rows[code] = row
ordered_rows = [rows.get(code) for _, code, _ in index_keys]
if all(ordered_rows):
index_context["indices"] = ordered_rows
changes = [float(row.get("pct_chg") or 0) for row in ordered_rows]
aggregate = dict(index_context.get("aggregate") or {})
aggregate["average_pct_chg"] = sum(changes) / 3
index_context["aggregate"] = aggregate
return dashboard, index_context, sector, stock
@classmethod
def _heaven_line_checks(
cls,
trade_date: str,
dashboard: dict[str, Any],
recent_history: list[dict[str, Any]],
index_context: dict[str, Any],
sector: dict[str, Any],
stock: dict[str, Any],
market_mode: str,
manual_data: dict[str, Any],
) -> list[dict[str, Any]]:
intraday = market_mode == "intraday"
closed = market_mode == "closed"
schema = cls._heaven_manual_schema(market_mode)
required = {
1: (["stock_amount_percentile", "stock_turnover_relative", "stock_volume_activity_ratio"] if intraday else ["stock_amount_percentile", "stock_turnover_rate", "stock_seal_amount_million", "stock_open_times"]),
2: ["stock_change", "stock_streak", "stock_status"],
3: (["sector_name", "sector_up_count", "sector_down_count", "sector_coverage", "sector_relative_turnover"] if intraday else ["sector_name", "sector_up_count", "sector_down_count", "sector_coverage", "sector_member_equal_change", "sector_leading_pct"]),
4: ["sector_name", "sector_change", "sector_leading_pct"],
5: ["market_sentiment_score", "market_seal_rate", "market_amount_billion", "market_recent_average_amount_billion", "market_up_count", "market_down_count", "market_limit_up_count", "market_limit_down_count"],
6: ["index_sh_change", "index_sz_change", "index_cy_change"],
}
names = {
1: ("初爻", "个股内核", "成交活跃、换手与量能"),
2: ("二爻", "个股外显", "涨跌、连板与状态"),
3: ("三爻", "行业内核", "行业宽度与成交活跃"),
4: ("四爻", "行业外显", "行业涨跌与领涨表现"),
5: ("五爻", "市场内核", "情绪、封板、成交与市场宽度"),
6: ("上爻", "指数外显", "三大指数当日涨跌"),
}
index_date = str(index_context.get("trade_date") or "").replace("-", "")
index_rows = list(index_context.get("indices") or [])
index_dates = {str(row.get("trade_date") or "").replace("-", "") for row in index_rows}
index_issues = []
if len(index_rows) < 3:
index_issues.append(f"三大指数仅取得 {len(index_rows)}/3 条行情")
elif index_date != trade_date or index_dates != {trade_date}:
actual_dates = "、".join(sorted(value for value in index_dates if value)) or "未知"
index_issues.append(f"指数实际日期为 {actual_dates},目标交易日为 {trade_date}")
elif not index_context.get("precise"):
index_issues.append("三大指数行情未通过完整性校验")
elif intraday and not index_context.get("realtime"):
index_issues.append("盘中缺少可核验的实时指数行情")
elif not intraday and (index_context.get("realtime") or str(index_context.get("source") or "") != "tushare"):
index_issues.append("收盘或历史行情不是官方指数日线")
sector_date = str(sector.get("trade_date") or "").replace("-", "")
sector_coverage = float(sector.get("coverage") or 0)
sector_explained_count = int(
sector.get("explained_count")
if sector.get("explained_count") is not None
else sector.get("quote_count") or 0
)
sector_explained_coverage = float(
sector.get("explained_coverage")
if sector.get("explained_coverage") is not None
else sector_coverage
)
sector_coverage_issue = _sector_coverage_issue(
int(sector.get("member_count") or 0),
int(sector.get("quote_count") or 0),
sector_explained_coverage,
sector_explained_count,
)
sector_common = []
if not sector:
sector_common.append("未取得申万二级行业归属")
elif sector.get("taxonomy") != "sw_l2":
sector_common.append("行业分类不是申万二级")
elif sector_date != trade_date:
sector_common.append("行业行情日期与目标交易日不一致")
elif intraday and not sector.get("realtime"):
sector_common.append("盘中行业行情不是申万实时行情")
elif market_mode == "historical" and sector.get("realtime"):
sector_common.append("历史行业行情不能使用实时快照")
elif closed and sector.get("realtime") and not sector.get("finalized"):
sector_common.append("收盘行业实时行情尚未形成15:00最终快照")
sector_inner = list(sector_common)
sector_outer = list(sector_common)
if not sector.get("inner_precise", sector.get("precise")):
sector_inner.append(str(sector.get("inner_error") or sector.get("error") or "行业内核数据未通过校验"))
if not sector.get("outer_precise", sector.get("precise")):
sector_outer.append(str(sector.get("outer_error") or sector.get("error") or "行业外显数据未通过校验"))
if sector and sector_coverage_issue and sector_coverage_issue not in sector_inner:
sector_inner.append(sector_coverage_issue)
if sector.get("realtime") and not sector.get("relative_turnover"):
sector_inner.append("缺少行业相对全市场换手活跃度")
stock_date = str(stock.get("trade_date") or "").replace("-", "")
stock_common = []
if not stock.get("code"):
stock_common.append("尚未载入有效个股")
elif stock_date != trade_date:
stock_common.append(f"个股实际日期为 {stock_date or '未知'},目标交易日为 {trade_date}")
elif not stock.get("precise"):
stock_common.append("个股行情未通过完整性校验")
elif intraday and not stock.get("realtime"):
stock_common.append("盘中个股行情不是实时行情")
elif not intraday and (stock.get("realtime") or str(stock.get("data_source") or "") != "tushare"):
stock_common.append("收盘或历史个股行情不是官方日线")
stock_inner = list(stock_common)
if intraday and stock.get("turnover_source") in {None, "", "unavailable"}:
stock_inner.append("缺少可核验的实时换手率")
if intraday and stock.get("activity_source") in {None, "", "unavailable"}:
stock_inner.append("缺少同时间进度量能基准")
overview = dashboard.get("overview") or {}
market_key_map = {
"market_sentiment_score": "sentiment_score", "market_seal_rate": "seal_rate",
"market_amount_billion": "amount_billion", "market_recent_average_amount_billion": "recent_average_amount_billion",
"market_up_count": "up_count", "market_down_count": "down_count",
"market_limit_up_count": "limit_up_count", "market_limit_down_count": "limit_down_count",
}
market_issues = []
for manual_key, source_key in market_key_map.items():
if source_key == "recent_average_amount_billion":
history_values = [item.get("amount_billion") for item in recent_history[:-1] if item.get("amount_billion") is not None]
if source_key not in overview and not history_values:
market_issues.append(f"缺少{schema[manual_key]['label']}")
elif source_key not in overview or overview.get(source_key) is None:
market_issues.append(f"缺少{schema[manual_key]['label']}")
automatic_issues = {
1: stock_inner, 2: stock_common, 3: sector_inner,
4: sector_outer, 5: market_issues, 6: index_issues,
}
limits = list(dashboard.get("limits") or [])
scores = _market_line_scores(dashboard, recent_history, index_context, sector, stock, limits)
value_map: dict[str, Any] = {
"stock_amount_percentile": stock.get("amount_percentile"),
"stock_turnover_rate": stock.get("turnover_rate"),
"stock_turnover_relative": stock.get("turnover_relative"),
"stock_volume_activity_ratio": stock.get("volume_activity_ratio"),
"stock_seal_amount_million": stock.get("seal_amount_million"),
"stock_open_times": stock.get("open_times"),
"stock_change": stock.get("change"), "stock_streak": stock.get("streak"),
"stock_status": stock.get("status"), "sector_name": sector.get("name"),
"sector_up_count": sector.get("up_count"), "sector_down_count": sector.get("down_count"),
"sector_coverage": sector.get("coverage"), "sector_relative_turnover": sector.get("relative_turnover"),
"sector_member_equal_change": sector.get("member_equal_change"),
"sector_change": sector.get("change"), "sector_leading_pct": sector.get("leading_pct"),
"market_sentiment_score": overview.get("sentiment_score"), "market_seal_rate": overview.get("seal_rate"),
"market_amount_billion": overview.get("amount_billion"),
"market_recent_average_amount_billion": overview.get("recent_average_amount_billion"),
"market_up_count": overview.get("up_count"), "market_down_count": overview.get("down_count"),
"market_limit_up_count": overview.get("limit_up_count"), "market_limit_down_count": overview.get("limit_down_count"),
}
history_values = [float(item.get("amount_billion")) for item in recent_history[:-1] if item.get("amount_billion") is not None]
if value_map["market_recent_average_amount_billion"] is None and history_values:
value_map["market_recent_average_amount_billion"] = sum(history_values) / len(history_values)
if value_map["stock_amount_percentile"] is None and not intraday:
amount = float(stock.get("amount_billion") or 0)
amounts = [float(item.get("amount_billion") or 0) for item in limits if item.get("amount_billion") is not None]
value_map["stock_amount_percentile"] = (
sum(item <= amount for item in amounts) / len(amounts) * 100 if amounts else None
)
row_by_code = {str(row.get("ts_code") or row.get("code") or ""): row for row in index_context.get("indices") or []}
value_map.update({
"index_sh_change": (row_by_code.get("000001.SH") or {}).get("pct_chg"),
"index_sz_change": (row_by_code.get("399001.SZ") or {}).get("pct_chg"),
"index_cy_change": (row_by_code.get("399006.SZ") or {}).get("pct_chg"),
})
def missing_value(key: str) -> bool:
value = value_map.get(key)
return value is None or (isinstance(value, str) and not value.strip())
invalid_fields = {
line_number: {key for key in keys if missing_value(key)}
for line_number, keys in required.items()
}
if stock_common:
invalid_fields[1].update(required[1])
invalid_fields[2].update(required[2])
else:
if intraday and stock.get("turnover_source") in {None, "", "unavailable"}:
invalid_fields[1].add("stock_turnover_relative")
if intraday and stock.get("activity_source") in {None, "", "unavailable"}:
invalid_fields[1].add("stock_volume_activity_ratio")
if sector_common:
invalid_fields[3].update(required[3])
invalid_fields[4].update(required[4])
else:
if not sector.get("inner_precise", sector.get("precise")) or sector_coverage_issue:
invalid_fields[3].update(key for key in required[3] if key != "sector_name")
if sector.get("realtime") and not sector.get("relative_turnover"):
invalid_fields[3].add("sector_relative_turnover")
# The official SW index supplies only the sector's external change. A valid
# membership name and member-stock leader remain usable when that quote fails.
if not sector.get("outer_precise", sector.get("precise")):
invalid_fields[4].add("sector_change")
if index_issues:
invalid_fields[6].update(required[6])
checks = []
for line_number in range(1, 7):
manual_keys = [key for key in required[line_number] if key in manual_data]
unresolved_fields = [
key for key in required[line_number]
if key in invalid_fields[line_number] and key not in manual_data
]
hard_missing_identity = line_number in {1, 2} and not stock.get("code")
passed = not hard_missing_identity and not unresolved_fields
status = "manual" if passed and manual_keys else "passed" if passed else "failed"
reasons = [] if passed else [
*( ["请先输入并载入股票代码或名称"] if hard_missing_identity else automatic_issues[line_number] ),
*( ["需补充:" + "、".join(schema[key]["label"] for key in unresolved_fields)] if unresolved_fields else [] ),
]
score = float(scores[line_number - 1]["score"])
position, layer, formula = names[line_number]
checks.append({
"line": line_number, "position": position, "layer": layer, "formula": formula,
"status": status, "passed": passed, "reasons": reasons,
"score": round(score, 3) if passed else None,
"line_value": _score_to_line(score) if passed else None,
"evidence": scores[line_number - 1]["evidence"] if passed else [],
"fields": [
{
"key": key, "label": schema[key]["label"], "unit": schema[key].get("unit", ""),
"type": schema[key].get("type", "number"), "options": schema[key].get("options", []),
"value": value_map.get(key), "manual": key in manual_data,
"required": True, "min": schema[key].get("min"), "max": schema[key].get("max"),
"integer": bool(schema[key].get("integer")),
}
for key in required[line_number]
],
})
return checks
def _resolve_heaven_stock_code(self, query: str) -> str:
raw = validate_text(query, "股票代码或名称", 30, required=True)
code_match = re.fullmatch(r"(\d{6})(?:\.(?:SH|SZ|BJ))?", raw.upper())
if code_match:
return validate_stock_code(code_match.group(1))
candidates = self.database.search_stock_master(raw)
exact = [item for item in candidates if str(item.get("name") or "").casefold() == raw.casefold()]
if not exact and self.configured:
try:
rows = self._tushare_client().query(
"stock_basic",
{"name": raw, "list_status": "L"},
"ts_code,symbol,name,industry,market,list_date",
)
except TushareError:
rows = []
if rows:
self.database.upsert_stock_master(rows)
candidates = self.database.search_stock_master(raw)
exact = [
item
for item in candidates
if str(item.get("name") or "").casefold() == raw.casefold()
]
matches = exact or candidates
if len(matches) == 1:
return validate_stock_code(str(matches[0].get("code") or ""))
if len(matches) > 1:
choices = "、".join(
f"{item.get('name') or '--'}{item.get('code') or '--'}"
for item in matches[:5]
)
raise ValueError(f"匹配到多只股票:{choices}。请输入六位股票代码。")
raise ValueError(f"未找到股票“{raw}”,请检查名称或输入六位股票代码。")
def heaven_setup(
self,
trade_date: str,
sector_name: str = "",
stock_code: str = "",
manual_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
dashboard = self.get_dashboard(normalized_date)
data_date = normalize_date(str(dashboard.get("meta", {}).get("trade_date") or normalized_date))
recent_history = self.database.snapshot_summaries(data_date, 10)
market_mode = self._heaven_market_mode(data_date, dashboard)
manual_data = self._validate_heaven_manual_data(manual_data, market_mode)
index_context = self._heaven_index_context(data_date, dashboard, market_mode)
external_stock = None
normalized_stock_code = ""
if stock_code.strip():
normalized_stock_code = self._resolve_heaven_stock_code(stock_code)
external_stock = self._heaven_stock_context(
normalized_stock_code,
data_date,
dashboard,
market_mode,
)
external_sector = None
if normalized_stock_code and self.configured:
external_sector = self._heaven_sector_context(
normalized_stock_code,
data_date,
market_mode,
)
if external_sector and external_stock:
external_stock["sector"] = external_sector.get("name") or external_stock.get("sector")
dashboard, index_context, external_sector, external_stock = self._apply_heaven_manual_data(
dashboard,
index_context,
external_sector,
external_stock,
manual_data,
market_mode,
data_date,
normalized_stock_code,
)
if external_sector and external_stock:
external_stock["sector"] = external_sector.get("name") or external_stock.get("sector")
sector_input = str((external_sector or {}).get("name") or sector_name.strip())
if not normalized_stock_code:
data_checks = []
chart = {
"available": False,
"selection_required": True,
"data_trade_date": data_date,
"sector": "",
"sector_code": "",
"sector_taxonomy": "",
"stock": {"code": "", "name": "", "status": ""},
"quality": {
"status": "awaiting_selection",
"issues": [],
"principle": "",
"sources": [],
},
"index_context": index_context,
}
else:
data_checks = self._heaven_line_checks(
data_date,
dashboard,
recent_history,
index_context,
external_sector or {},
external_stock or {},
market_mode,
manual_data,
)
quality_issues = [
f"{check['position']}·{check['layer']}{''.join(check['reasons'])}"
for check in data_checks
if not check["passed"]
]
if quality_issues:
chart = {
"available": False,
"selection_required": False,
"data_trade_date": data_date,
"sector": str((external_sector or {}).get("name") or sector_input or "--"),
"sector_code": str((external_sector or {}).get("code") or ""),
"sector_taxonomy": str((external_sector or {}).get("taxonomy") or ""),
"stock": {
"code": normalized_stock_code,
"name": str((external_stock or {}).get("name") or "--"),
"status": str((external_stock or {}).get("status") or ""),
},
"quality": {
"status": "blocked",
"issues": quality_issues,
"principle": "六爻任一层缺少同日、同口径的有效数据,本系统不成卦。",
"sources": self._heaven_trend_sources(
data_date, index_context, external_sector, external_stock
),
},
"index_context": index_context,
}
else:
chart = build_market_hexagram(
dashboard,
recent_history,
index_context,
sector_input,
normalized_stock_code,
external_stock,
external_sector,
)
chart["available"] = True
chart["selection_required"] = False
manual_active = any(check["status"] == "manual" for check in data_checks)
chart["quality"] = {
"status": "manual" if manual_active else "verified",
"issues": [],
"principle": (
"自动行情与用户补充数据均已通过同一套量化公式校验。"
if manual_active
else "指数、板块、个股均已通过同日同口径校验。"
),
"sources": [
*self._heaven_trend_sources(
data_date, index_context, external_sector, external_stock
),
*([{
"lines": "补录爻位",
"layer": "用户补充",
"realtime": market_mode == "intraday",
"detail": str(manual_data.get("note") or "量化数据经原公式重新计算"),
}] if manual_active else []),
],
}
chart["data_checks"] = data_checks
chart["manual_data"] = manual_data
sector_phase_overrides = self.database.list_sector_phase_overrides()
field = build_five_phase_field(
normalized_date,
sector_phase_overrides,
)
personal_profile = self.account_personal_field(
normalized_date,
field,
public=True,
)
daily_fortune_reading = self.database.latest_heaven_reading(
self.current_user_id, "fortune", normalized_date
)
if self._legacy_truncated_heaven_reading(daily_fortune_reading):
daily_fortune_reading = None
return {
"trade_date": data_date,
"calendar_date": normalized_date,
"market_mode": market_mode,
"chart": chart,
"field": field,
"personal_profile": personal_profile,
"daily_fortune_reading": daily_fortune_reading,
"sector_phase_overrides": [
{"name": name, "element": element}
for name, element in sector_phase_overrides.items()
],
"llm": {
"configured": self.llm_configured,
"model": self.llm_primary_model if self.llm_configured else "",
"fallback_configured": self.llm_fallback_configured,
"fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
},
}
def _heaven_stock_context(
self,
stock_code: str,
trade_date: str,
dashboard: dict[str, Any],
market_mode: str,
) -> dict[str, Any]:
"""Return the only stock contract accepted by heaven trend."""
pool_row = next(
(
dict(row) for key in ("limits", "broken", "down_limits")
for row in dashboard.get(key) or []
if str(row.get("code") or "") == stock_code
),
{},
)
if market_mode == "intraday":
if self.configured:
try:
quote = self._tushare_client().realtime_stock_quote(
tushare_code(stock_code),
trade_date,
)
return {
**quote,
"status": pool_row.get("status") or "普通",
"seal_amount_million": pool_row.get("seal_amount_million") or 0,
"open_times": pool_row.get("open_times") or 0,
"streak": pool_row.get("streak") or 0,
"precise": True,
}
except TushareError:
pass
if pool_row:
return {
**pool_row,
"data_source": "dashboard_rt" if dashboard.get("meta", {}).get("realtime") else "dashboard",
"trade_date": trade_date,
"realtime": bool(dashboard.get("meta", {}).get("realtime")),
"precise": False,
}
return {
"code": stock_code,
"name": "--",
"sector": "其他",
"trade_date": trade_date,
"realtime": False,
"precise": False,
}
detail = self.get_stock_detail(stock_code, trade_date, force=True)
detail_meta = detail.get("meta") or {}
stock = detail.get("stock") or {}
resolved_date = normalize_date(str(detail_meta.get("trade_date") or trade_date))
source = str(detail_meta.get("source") or "")
return {
"code": stock_code,
"name": stock.get("name") or pool_row.get("name") or "--",
"sector": stock.get("industry") or pool_row.get("sector") or "其他",
"status": pool_row.get("status") or "普通",
"change": stock.get("change") or 0,
"turnover_rate": stock.get("turnover_rate") or 0,
"amount_billion": stock.get("amount_billion") or 0,
"seal_amount_million": pool_row.get("seal_amount_million") or 0,
"open_times": pool_row.get("open_times") or 0,
"streak": pool_row.get("streak") or 0,
"data_source": source,
"trade_date": resolved_date,
"realtime": False,
"precise": source == "tushare" and resolved_date == trade_date,
}
@staticmethod
def _heaven_market_mode(
trade_date: str,
dashboard: dict[str, Any],
now: datetime | None = None,
) -> str:
"""区分盘中、今日收盘和历史,避免把 rt_k 数据来源误当成交易状态。"""
now = now or datetime.now().astimezone()
if trade_date != now.strftime("%Y%m%d"):
return "historical"
meta = dashboard.get("meta") or {}
status = str(meta.get("market_status") or "").lower()
local_time = now.time().replace(tzinfo=None)
if status == "closed" or local_time > datetime.strptime("15:05", "%H:%M").time():
return "closed"
if status in {"trading", "auction", "pre_open"} or (
bool(meta.get("realtime"))
and local_time >= datetime.strptime("09:15", "%H:%M").time()
):
return "intraday"
return "historical"
@staticmethod
def _heaven_trend_sources(
trade_date: str,
index_context: dict[str, Any],
sector: dict[str, Any] | None,
stock: dict[str, Any] | None,
) -> list[dict[str, Any]]:
sector = sector or {}
stock = stock or {}
return [
{
"lines": "五爻、上爻",
"layer": "指数",
"source": index_context.get("source") or "unavailable",
"trade_date": index_context.get("trade_date") or "",
"realtime": bool(index_context.get("realtime")),
"detail": f"三大指数 {len(index_context.get('indices') or [])}/3",
},
{
"lines": "三爻、四爻",
"layer": "行业",
"source": sector.get("source") or "unavailable",
"trade_date": sector.get("trade_date") or "",
"realtime": bool(sector.get("realtime")),
"detail": (
f"申万二级 {sector.get('name') or '--'} {sector.get('code') or '--'} "
f"成分覆盖 {int(sector.get('quote_count') or 0)}/{int(sector.get('member_count') or 0)}"
),
},
{
"lines": "初爻、二爻",
"layer": "个股",
"source": stock.get("data_source") or "unavailable",
"trade_date": stock.get("trade_date") or trade_date,
"realtime": bool(stock.get("realtime")),
"detail": (
f"{stock.get('name') or '--'};换手基准 "
f"{stock.get('capital_trade_date') or '--'}"
),
},
]
@staticmethod
def _heaven_trend_quality_issues(
trade_date: str,
dashboard: dict[str, Any],
index_context: dict[str, Any],
sector: dict[str, Any] | None,
stock: dict[str, Any] | None,
market_mode: str = "historical",
) -> list[str]:
issues: list[str] = []
intraday = market_mode == "intraday"
closed = market_mode == "closed"
if intraday:
meta = dashboard.get("meta") or {}
market_status = str(meta.get("market_status") or "")
now = datetime.now().astimezone()
try:
updated_at = datetime.fromisoformat(str(meta.get("updated_at") or ""))
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=now.tzinfo)
snapshot_age = (now - updated_at.astimezone(now.tzinfo)).total_seconds()
except ValueError:
snapshot_age = float("inf")
if market_status in {"trading", "auction", "pre_open"} and snapshot_age > 120:
issues.append("主行情快照超过2分钟,请点击顶部刷新")
# 收盘后不再用 dashboard.market_status 作为阻断条件。盘后同步可能将
# rt_k 快照替换成同日盘后日线而不带该字段;六爻数据本身的日期、
# 完整性和来源校验已足以判断是否可以成卦。
index_date = str(index_context.get("trade_date") or "").replace("-", "")
index_rows = list(index_context.get("indices") or [])
index_row_dates = {
str(row.get("trade_date") or "").replace("-", "") for row in index_rows
}
if not index_context.get("precise") or len(index_rows) < 3:
issues.append("指数层缺少三大指数的有效行情")
elif index_date != trade_date or index_row_dates != {trade_date}:
issues.append("指数行情与目标交易日不一致")
elif intraday and not index_context.get("realtime"):
issues.append("盘中指数层缺少可核验的实时行情")
elif not intraday and (
index_context.get("realtime")
or str(index_context.get("source") or "") != "tushare"
):
issues.append("历史/收盘指数层必须使用 Tushare 官方指数日线")
sector = sector or {}
sector_date = str(sector.get("trade_date") or "").replace("-", "")
sector_coverage = float(sector.get("coverage") or 0)
sector_explained_count = int(
sector.get("explained_count")
if sector.get("explained_count") is not None
else sector.get("quote_count") or 0
)
sector_explained_coverage = float(
sector.get("explained_coverage")
if sector.get("explained_coverage") is not None
else sector_coverage
)
sector_coverage_issue = _sector_coverage_issue(
int(sector.get("member_count") or 0),
int(sector.get("quote_count") or 0),
sector_explained_coverage,
sector_explained_count,
)
if not sector:
issues.append("行业层缺少申万二级行业归属")
elif sector.get("taxonomy") != "sw_l2":
issues.append("行业层必须使用申万二级行业分类")
elif sector_date != trade_date:
issues.append("行业行情与目标交易日不一致")
elif intraday and not sector.get("realtime"):
issues.append("盘中行业层缺少申万实时行情")
elif market_mode == "historical" and sector.get("realtime"):
issues.append("历史行业层不能使用实时快照")
elif closed and sector.get("realtime") and not sector.get("finalized"):
issues.append("收盘行业层缺少15:00最终快照")
if not sector.get("inner_precise", sector.get("precise")):
issues.append("行业内核缺少可核验的成分行情")
if not sector.get("outer_precise", sector.get("precise")):
issues.append("行业外显缺少申万官方行情")
if sector and sector_coverage_issue:
issues.append(sector_coverage_issue)
if sector.get("realtime") and not sector.get("relative_turnover"):
issues.append("行业内核缺少相对全市场换手活跃度")
stock = stock or {}
stock_date = str(stock.get("trade_date") or "").replace("-", "")
if not stock or not stock.get("code"):
issues.append("个股层尚未载入有效标的")
elif not stock.get("precise"):
issues.append("个股层缺少可核验的行情数据")
elif stock_date != trade_date:
issues.append("个股行情与目标交易日不一致")
elif intraday and not stock.get("realtime"):
issues.append("盘中个股层不是 rt_k 实时行情")
elif not intraday and (
stock.get("realtime")
or str(stock.get("data_source") or "") != "tushare"
):
issues.append("历史/收盘个股层必须使用 Tushare 官方日线")
if intraday and stock and not stock.get("turnover_source"):
issues.append("个股内核缺少可核验的实时换手率")
elif intraday and stock.get("turnover_source") == "unavailable":
issues.append("个股内核缺少流通股本,无法计算实时换手率")
if intraday and stock.get("activity_source") == "unavailable":
issues.append("个股内核缺少近5日量能基准")
elif intraday and not stock.get("activity_source"):
issues.append("个股内核缺少同时间进度量能")
return issues
def heaven_personal(self, payload: dict[str, Any]) -> dict[str, Any]:
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
field = build_five_phase_field(
trade_date,
self.database.list_sector_phase_overrides(),
)
personal = self.account_personal_field(trade_date, field, public=True)
if not personal:
raise ValueError("请先在账号设置中保存个人命理资料。")
return personal
def heaven_hexagram(self, raw_lines: Any) -> dict[str, Any]:
if not isinstance(raw_lines, list):
raise ValueError("六爻起卦结果格式不正确。")
try:
lines = [int(value) for value in raw_lines]
except (TypeError, ValueError) as exc:
raise ValueError("六爻必须由六、七、八、九组成。") from exc
return hexagram_from_lines(lines)
def heaven_readings(
self, mode: str, context_date: str = "", limit: int = 100
) -> dict[str, Any]:
mode = str(mode or "").strip()
if mode not in {"trend", "fortune", "heart"}:
raise ValueError("解读记录类型不正确。")
normalized_date = normalize_date(context_date) if context_date else ""
return {
"mode": mode,
"items": self.database.list_heaven_readings(
self.current_user_id, mode, normalized_date, limit
),
}
@staticmethod
def _heaven_reading_identity(
mode: str, context_date: str, context: dict[str, Any]
) -> tuple[str, str]:
display_date = DashboardService._display_compact_date(context_date)
if mode == "trend":
stock = (context.get("selected_focus") or {}).get("stock") or {}
code = str(stock.get("code") or "").strip()
name = str(stock.get("name") or "").strip()
hexagram = context.get("hexagram") or {}
transformed = hexagram.get("transformed") or {}
subject = " ".join(item for item in (code, name) if item) or "观势"
detail = f"{display_date} · {hexagram.get('name') or '--'}{transformed.get('name') or '--'}"
return subject, detail
if mode == "fortune":
field = context.get("five_phase_field") or {}
pillars = field.get("pillars") or {}
dominant = (field.get("balance") or [{}])[0]
subject = f"{display_date} 观气"
detail = (
f"{pillars.get('year') or '--'}年 · {pillars.get('month') or '--'}月 · "
f"{pillars.get('day') or '--'}日 · {dominant.get('element') or '--'}气偏显"
)
return subject, detail
hexagram = context.get("hexagram") or {}
transformed = hexagram.get("transformed") or {}
return (
f"{display_date} 观心",
f"{hexagram.get('name') or '--'}{transformed.get('name') or '--'}",
)
def heaven_interpret(self, payload: dict[str, Any]) -> dict[str, Any]:
mode = str(payload.get("mode") or "").strip()
if mode not in {"trend", "fortune", "heart"}:
raise ValueError("问天解读模式不正确。")
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
if mode == "fortune":
existing = self.database.latest_heaven_reading(
self.current_user_id, "fortune", trade_date
)
if self._legacy_truncated_heaven_reading(existing):
self.database.delete_heaven_reading(
self.current_user_id, int(existing["id"])
)
existing = None
if existing:
return {
"answer": existing["answer"],
"mode": mode,
"compiler": "stored",
"notice": "",
"reading": existing,
"reused": True,
}
if mode in {"trend", "fortune"}:
setup = self.heaven_setup(
trade_date,
str(payload.get("sector") or ""),
str(payload.get("stock_code") or ""),
payload.get("manual_data"),
)
if mode == "trend":
chart = setup["chart"]
if not chart.get("available"):
issues = "".join((chart.get("quality") or {}).get("issues") or [])
raise ValueError(f"观势数据未通过六爻校验,暂不解势:{issues}")
hexagram_context = json.loads(json.dumps(chart["hexagram"], ensure_ascii=False))
for line in hexagram_context.get("lines", []):
line.pop("evidence", None)
line.pop("score", None)
line.pop("talent", None)
line.pop("layer", None)
line.pop("role", None)
if not line.get("moving"):
line.pop("text", None)
line.pop("image", None)
line.pop("line_name", None)
context = {
"data_trade_date": setup["trade_date"],
"selected_focus": {
"sector": chart.get("sector") or "",
"stock": chart.get("stock") or {},
},
"hexagram": hexagram_context,
"movement": chart.get("movement") or {},
}
else:
personal_profile = self.account_personal_field(
setup["calendar_date"],
setup["field"],
public=False,
)
fortune_field = json.loads(json.dumps(setup["field"], ensure_ascii=False))
catalog = fortune_field.pop("sector_catalog", [])
dominant_elements = {
item.get("element") for item in fortune_field.get("balance", [])[:2]
}
fortune_field["industry_affinity"] = [
{
"element": group.get("element"),
"examples": [
item.get("name")
for item in group.get("industries", [])[:8]
if item.get("name")
],
}
for group in catalog
if group.get("element") in dominant_elements
]
context = {
"calendar_date": setup["calendar_date"],
"five_phase_field": fortune_field,
"personal_profile": personal_profile,
}
context_date = setup["calendar_date"]
if mode == "trend":
context_date = setup["trade_date"]
else:
context = {
"hexagram": self.heaven_hexagram(payload.get("lines")),
"ritual": "用户已完成30秒静心、六次三枚铜钱起卦,并在心中察看第一念。问题未输入。",
}
context_date = trade_date
result, compiler = self._call_heaven_agent(mode, context)
subject, subject_detail = self._heaven_reading_identity(
mode, context_date, context
)
dedupe_key = (
f"fortune:{context_date}"
if mode == "fortune"
else f"{mode}:{context_date}:{secrets.token_urlsafe(12)}"
)
reading = self.database.save_heaven_reading(
self.current_user_id,
mode,
context_date,
subject,
subject_detail,
str(result.get("answer") or ""),
context,
dedupe_key,
)
return {
**result,
"mode": mode,
"compiler": compiler,
"notice": "智能解读已自动切换可用服务。" if compiler == "fallback" else "",
"reading": reading,
"reused": False,
}
@staticmethod
def _legacy_truncated_heaven_reading(reading: dict[str, Any] | None) -> bool:
return bool(reading and str(reading.get("answer") or "").rstrip().endswith("……"))
def _call_heaven_agent(self, mode: str, context: dict[str, Any]) -> tuple[dict[str, Any], str]:
result = self.llm_gateway.call(
f"heaven_{mode}",
f"heaven-{mode}-v1",
lambda profile: interpret_heaven(
mode,
context,
profile.api_key,
profile.base_url,
profile.model,
),
(HeavenAgentError,),
)
return result.value, result.role
def _heaven_index_context(
self,
trade_date: str,
dashboard: dict[str, Any],
market_mode: str = "historical",
) -> dict[str, Any]:
cached = self.database.get_data_snapshot("heaven_indices", trade_date)
cached_valid = False
if cached:
cached_rows = list(cached.get("indices") or [])
cached_dates = {
str(row.get("trade_date") or "").replace("-", "")
for row in cached_rows
}
cached_valid = (
len(cached_rows) == 3
and cached_dates == {trade_date}
and bool(cached.get("precise"))
and not cached.get("realtime")
and str(cached.get("source") or "") == "tushare"
and int(cached.get("schema_version") or 0) >= 3
)
if market_mode != "intraday" and cached_valid:
return cached
if not self.configured:
error = "Tushare Token 未配置"
else:
try:
client = self._tushare_client()
if market_mode == "intraday":
payload = self._aggregate_index_context(trade_date)
payload["schema_version"] = 3
return payload
payload = client.market_indices(trade_date)
payload["schema_version"] = 3
if market_mode == "closed":
payload["finalized"] = True
self.database.save_data_snapshot(
"heaven_indices",
trade_date,
str(payload.get("source") or "tushare"),
payload,
)
return payload
except Exception as exc:
error = str(exc)
overview = dashboard.get("overview") or {}
up_count = float(overview.get("up_count") or 0)
down_count = float(overview.get("down_count") or 0)
breadth = (up_count - down_count) / max(up_count + down_count, 1)
return {
"source": "market_breadth_proxy",
"trade_date": trade_date,
"realtime": False,
"precise": False,
"schema_version": 3,
"notice": f"指数数据不可用,当前以市场宽度代理:{error}",
"indices": [],
"aggregate": {
"average_pct_chg": round(breadth * 2.5, 3),
"average_return_5d": 0,
"average_return_20d": 0,
},
}
def _aggregate_index_context(
self,
trade_date: str,
tushare_error: str = "",
) -> dict[str, Any]:
quotes = self.realtime_aggregator.tencent_indices()
epochs = [int(item.get("quote_time_epoch") or 0) for item in quotes]
quote_dates = {
datetime.fromtimestamp(epoch).astimezone().strftime("%Y%m%d")
for epoch in epochs if epoch
}
if len(quotes) != 3 or quote_dates != {trade_date}:
raise ValueError("腾讯三大指数日期与目标交易日不一致")
now = datetime.now().astimezone()
max_skew = 120 if now.hour >= 15 else 15
if max(epochs) - min(epochs) > max_skew:
raise ValueError(f"腾讯三大指数时间差超过{max_skew}秒")
code_map = {
"000001": "000001.SH",
"399001": "399001.SZ",
"399006": "399006.SZ",
}
client = self._tushare_client()
indices = []
start_date = (
datetime.strptime(trade_date, "%Y%m%d") - timedelta(days=20)
).strftime("%Y%m%d")
for quote in quotes:
ts_code = code_map[str(quote.get("code") or "")]
history = client.query(
"index_daily",
{"ts_code": ts_code, "start_date": start_date, "end_date": trade_date},
"ts_code,trade_date,close,pct_chg",
)
history.sort(key=lambda item: str(item.get("trade_date") or ""))
completed_closes = [
float(item.get("close") or 0)
for item in history
if str(item.get("trade_date") or "") < trade_date
and float(item.get("close") or 0) > 0
]
close_5d = (
completed_closes[-5]
if len(completed_closes) >= 5
else completed_closes[0] if completed_closes else 0
)
close = float(quote.get("price") or 0)
indices.append(
{
"ts_code": ts_code,
"name": quote.get("name") or ts_code,
"trade_date": trade_date,
"close": close,
"pct_chg": round(float(quote.get("change") or 0), 3),
"return_5d": round((close / close_5d - 1) * 100, 3) if close_5d else 0,
"return_20d": 0,
"amount_billion": float(quote.get("amount_billion") or 0),
"quote_time": quote.get("quote_time") or "",
}
)
return {
"trade_date": trade_date,
"source": "+".join(
sorted({str(item.get("source") or "web_quote") for item in quotes})
+ ["tushare_index_daily"]
),
"realtime": True,
"precise": True,
"indices": indices,
"aggregate": {
"average_pct_chg": round(
sum(item["pct_chg"] for item in indices) / len(indices), 3
),
"average_return_5d": round(
sum(item["return_5d"] for item in indices) / len(indices), 3
),
"average_return_20d": 0,
},
"quote_time_skew_seconds": max(epochs) - min(epochs),
"notice": (
"指数实时行情来自腾讯行情,5日趋势来自Tushare历史指数。"
+ (f" Tushare实时指数未使用:{tushare_error}" if tushare_error else "")
),
}
def _heaven_sector_context(
self,
identifier: str,
trade_date: str,
market_mode: str = "historical",
) -> dict[str, Any] | None:
"""Return the Shenwan L2 sector context for heaven trend.
观势行业层只使用申万二级行业。外显盘中使用 rt_sw_k、历史使用
sw_daily;内核独立使用目标日期成分股行情聚合。收盘过渡期在
sw_daily 入库前接受同日15:00后的 rt_sw_k 收盘快照。
"""
cache_key = f"{trade_date}:{identifier.strip().lower()}"
cached = self.database.get_data_snapshot("heaven_sector", cache_key)
cached_date = str((cached or {}).get("trade_date") or "").replace("-", "")
cached_valid = bool(
cached
and cached_date == trade_date
and cached.get("taxonomy") == "sw_l2"
and cached.get("inner_precise", cached.get("precise"))
and cached.get("outer_precise", cached.get("precise"))
and not cached.get("realtime")
and int(cached.get("schema_version") or 0) >= 6
)
if market_mode != "intraday" and cached_valid:
return cached
if not self.configured:
return None
try:
payload = self._tushare_client().sw_sector_snapshot(
tushare_code(identifier),
trade_date,
realtime_expected=market_mode == "intraday",
allow_realtime_close=market_mode == "closed",
)
except TushareError as exc:
if cached_valid:
return cached
return {
"name": "",
"code": "",
"taxonomy": "sw_l2",
"source": "tushare",
"trade_date": trade_date,
"realtime": market_mode == "intraday",
"precise": False,
"inner_precise": False,
"outer_precise": False,
"coverage": 0,
"member_count": 0,
"quote_count": 0,
"error": f"申万二级行业数据获取失败:{exc}",
}
if not payload.get("realtime") and payload.get("precise"):
self.database.save_data_snapshot(
"heaven_sector",
cache_key,
str(payload.get("source") or "tushare"),
payload,
)
return payload
@staticmethod
def _validate_mentor_history(raw_history: Any) -> list[dict[str, str]]:
if not isinstance(raw_history, list):
raise ValueError("问师对话历史格式不正确。")
history = []
total_length = 0
for item in raw_history[-12:]:
if not isinstance(item, dict) or item.get("role") not in {"user", "assistant"}:
raise ValueError("问师对话历史包含无效消息。")
content = str(item.get("content") or "").strip()
if not content or len(content) > 5000:
raise ValueError("问师对话历史消息为空或过长。")
total_length += len(content)
if total_length > 24_000:
raise ValueError("问师对话历史过长,请清空后重新提问。")
history.append({"role": item["role"], "content": content})
return history
def _build_mentor_context(
self, trade_date: str, question: str, skill: Any | None = None
) -> dict[str, Any]:
dashboard = self.get_dashboard(trade_date)
data_trade_date = normalize_date(
str(dashboard.get("meta", {}).get("trade_date") or trade_date)
)
regime = self.screener.detect_regime(data_trade_date)
limits = list(dashboard.get("limits") or [])
broken = list(dashboard.get("broken") or [])
down_limits = list(dashboard.get("down_limits") or [])
yesterday_limits = list(dashboard.get("yesterday_limits") or [])
all_stocks = limits + broken + down_limits + yesterday_limits
matched_rows = []
codes = re.findall(r"(?<!\d)\d{6}(?!\d)", question)[:3]
for row in all_stocks:
code = str(row.get("code") or "")
name = str(row.get("name") or "")
if code in codes or (len(name) >= 2 and name in question):
if not any(item.get("code") == code for item in matched_rows):
matched_rows.append(row)
for row in matched_rows:
code = str(row.get("code") or "")
if code and code not in codes:
codes.append(code)
stock_details = []
for code in codes[:2]:
try:
detail = self.get_stock_detail(code, data_trade_date)
stock_details.append(
{
"stock": detail.get("stock") or {},
"moneyflow": detail.get("moneyflow") or {},
"recent_prices": (detail.get("prices") or [])[-20:],
}
)
except Exception as exc:
stock_details.append({"code": code, "error": str(exc)})
skill_id = str(getattr(skill, "skill_id", "") or "")
profile = next(
(
profile_name
for profile_name, skill_ids in MENTOR_DATA_PROFILES.items()
if skill_id in skill_ids
),
"balanced",
)
dragon_tiger = None
if any(keyword in question for keyword in ("龙虎榜", "席位", "机构", "游资")):
try:
dragon_payload = self.get_dragon_tiger(data_trade_date)
rows = list(dragon_payload.get("rows") or [])
matched_dragon = [row for row in rows if str(row.get("code") or "") in codes]
leading_dragon = sorted(
rows,
key=lambda row: abs(float(row.get("net_buy_million") or 0)),
reverse=True,
)[:12]
dragon_tiger = {
"summary": dragon_payload.get("summary") or {},
"matched": matched_dragon,
"largest_net_flows": leading_dragon,
}
except Exception as exc:
dragon_tiger = {"error": str(exc)}
context: dict[str, Any] = {
"data_trade_date": data_trade_date,
"data_profile": profile,
"overview": dashboard.get("overview") or {},
"market_regime": regime,
"recent_market_history": self.database.snapshot_summaries(data_trade_date, 10),
"question_matched_stocks": matched_rows[:10],
"stock_details": stock_details,
}
ordered_limits = sorted(
limits,
key=lambda row: (
float(row.get("streak") or 0),
float(row.get("amount_billion") or 0),
),
reverse=True,
)
if profile in {"emotion", "balanced"}:
context.update(
{
"limit_ladder": dashboard.get("ladders") or [],
"limit_performance": dashboard.get("limit_performance") or [],
"hot_sectors": (dashboard.get("sectors") or [])[:15],
"sector_rotation": (dashboard.get("sector_rotation") or [])[:15],
"limit_up_stocks": ordered_limits[:30],
"broken_stocks": sorted(
broken,
key=lambda row: float(row.get("amount_billion") or 0),
reverse=True,
)[:20],
"limit_down_stocks": down_limits[:20],
"yesterday_limit_performance": sorted(
yesterday_limits,
key=lambda row: float(row.get("change") or 0),
reverse=True,
)[:20],
}
)
elif profile == "first_board":
context.update(
{
"first_board_environment": {
"seal_rate": (dashboard.get("overview") or {}).get("seal_rate"),
"broken_count": len(broken),
"first_boards": [row for row in ordered_limits if int(row.get("streak") or 1) == 1][:35],
"broken_stocks": sorted(
broken,
key=lambda row: float(row.get("amount_billion") or 0),
reverse=True,
)[:30],
},
"hot_sectors": (dashboard.get("sectors") or [])[:12],
}
)
elif profile == "leader":
context.update(
{
"limit_ladder": dashboard.get("ladders") or [],
"multi_board_leaders": [
row for row in ordered_limits if int(row.get("streak") or 0) >= 2
][:25],
"hot_sectors": (dashboard.get("sectors") or [])[:12],
"sector_rotation": (dashboard.get("sector_rotation") or [])[:12],
}
)
try:
popularity = self.popularity(data_trade_date)
context["popularity_core"] = {
"consensus": [
row for row in (popularity.get("combined") or [])
if row.get("dual_source")
][:10],
"ths": (popularity.get("ths") or [])[:10],
"eastmoney": (popularity.get("dc") or [])[:10],
}
except Exception:
context["popularity_core"] = {"unavailable": True}
elif profile == "trend":
context.update(
{
"index_momentum": self._mentor_market_matrix(
data_trade_date, MENTOR_INDEX_UNIVERSE
),
"sector_rotation": (dashboard.get("sector_rotation") or [])[:20],
"hot_sectors": (dashboard.get("sectors") or [])[:20],
"market_breadth": {
key: (dashboard.get("overview") or {}).get(key)
for key in ("up_count", "down_count", "flat_count", "amount_billion")
},
}
)
elif profile == "low_absorption":
context.update(
{
"yesterday_limit_performance": sorted(
yesterday_limits,
key=lambda row: float(row.get("change") or 0),
reverse=True,
)[:35],
"broken_stocks": broken[:20],
"hot_sectors": (dashboard.get("sectors") or [])[:12],
}
)
elif profile == "macro":
context.update(
{
"broad_indexes": self._mentor_market_matrix(
data_trade_date, MENTOR_INDEX_UNIVERSE
),
"core_etfs": self._mentor_market_matrix(
data_trade_date, MENTOR_ETF_UNIVERSE
),
"market_style": {
"amount_billion": (dashboard.get("overview") or {}).get("amount_billion"),
"breadth": {
"up": (dashboard.get("overview") or {}).get("up_count"),
"down": (dashboard.get("overview") or {}).get("down_count"),
},
"top_sectors": (dashboard.get("sectors") or [])[:15],
},
"unavailable_data": [
"政策原文与隔夜资讯尚未接入",
"汇率、利率和商品宏观序列当前不可用",
],
}
)
if dragon_tiger is not None:
context["dragon_tiger"] = dragon_tiger
return context
def _mentor_market_matrix(
self, trade_date: str, universe: tuple[tuple[str, str], ...]
) -> list[dict[str, Any]]:
ifind = getattr(self, "ifind", None)
if not ifind or not ifind.configured:
return []
end = datetime.strptime(trade_date, "%Y%m%d")
start = (end - timedelta(days=45)).strftime("%Y%m%d")
names = {code: name for code, name in universe}
try:
rows = ifind.history(
list(names), ["close", "volume", "amount"], start, trade_date, cache_ttl=600
)
except IfindError:
return []
grouped: dict[str, list[dict[str, Any]]] = {}
for row in rows:
code = str(row.get("thscode") or "").upper()
if code in names:
grouped.setdefault(code, []).append(row)
result = []
for code, name in universe:
series = sorted(grouped.get(code, []), key=lambda row: str(row.get("time") or ""))
closes = []
for row in series:
try:
close = float(row.get("close") or 0)
except (TypeError, ValueError):
continue
if close > 0:
closes.append(close)
if not closes:
continue
def period_return(days: int) -> float | None:
if len(closes) <= days or closes[-days - 1] <= 0:
return None
return round((closes[-1] / closes[-days - 1] - 1) * 100, 2)
previous = closes[-2] if len(closes) > 1 else 0
result.append(
{
"code": code,
"name": name,
"close": round(closes[-1], 3),
"change": round((closes[-1] / previous - 1) * 100, 2) if previous else None,
"return_5d": period_return(5),
"return_10d": period_return(10),
"return_20d": period_return(20),
"latest_amount": series[-1].get("amount") if series else None,
}
)
return result
def run_screener(self, payload: dict[str, Any]) -> dict[str, Any]:
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
regime = str(payload.get("regime") or "")
if regime not in REGIMES:
raise ValueError("市场阶段不支持。")
strategy_name = validate_text(payload.get("strategy_name"), "策略名称", 60, required=True)
formula = payload.get("formula") or {}
requested_mode = str(payload.get("mode") or "").strip()
if requested_mode and requested_mode not in {"smart", "curated", "quant"}:
raise ValueError("选股模式不受支持。")
if requested_mode:
mode = requested_mode
else:
meta = formula.get("meta") if isinstance(formula, dict) else {}
library = str((meta or {}).get("library") or "")
category = str((meta or {}).get("category") or "")
if library == "curated":
mode = "curated"
elif library == "quant" or (library == "custom" and category == "量化公式"):
mode = "quant"
else:
mode = "smart"
realtime_snapshot = None
dashboard = self.get_dashboard(trade_date)
if self.configured and dashboard.get("meta", {}).get("realtime"):
try:
realtime_snapshot = self._tushare_client().realtime_factor_snapshot(trade_date)
except TushareError as exc:
raise ValueError(f"实时选股行情不可用,已停止筛选:{exc}") from exc
result = self.screener.screen(
self.current_user_id, trade_date, formula, regime, strategy_name,
bool(payload.get("run_backtest", True)),
realtime_snapshot,
mode,
)
return result
def get_hot_money_profiles(self, force: bool = False) -> dict[str, Any]:
cache_kind = "hot_money_profiles_v1"
cache_key = "directory"
cached = self.database.get_data_snapshot(cache_kind, cache_key)
if cached and not force:
cached["meta"] = {**cached.get("meta", {}), "cached": True}
return cached
if self.configured:
try:
payload = self._tushare_client().hot_money_profiles()
except TushareError:
if cached:
cached["meta"] = {
**cached.get("meta", {}),
"cached": True,
"stale": True,
"notice": "名录暂未完成更新,当前展示最近一次收录结果。",
}
return cached
return {
"meta": {
"source": "unavailable",
"status": "unavailable",
"schema_version": 1,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "游资名录暂不可用,请稍后重试。",
},
"summary": {
"profile_count": 0,
"described_count": 0,
"organization_count": 0,
},
"profiles": [],
}
payload["meta"]["cached"] = False
if payload.get("meta", {}).get("status") == "success":
self.database.save_data_snapshot(cache_kind, cache_key, "tushare", payload)
return payload
if cached:
cached["meta"] = {**cached.get("meta", {}), "cached": True}
return cached
return {
"meta": {
"source": "unavailable",
"status": "unavailable",
"schema_version": 1,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "游资名录暂不可用,请联系管理员检查行情配置。",
},
"summary": {
"profile_count": 0,
"described_count": 0,
"organization_count": 0,
},
"profiles": [],
}
def get_dragon_tiger(self, trade_date: str, force: bool = False) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
cache_kind = "hot_money_detail_v3"
if not force:
cached = self.database.get_data_snapshot(cache_kind, normalized_date)
if (
cached
and cached.get("meta", {}).get("source") == "tushare"
and cached.get("meta", {}).get("status") == "success"
and int(cached.get("meta", {}).get("schema_version") or 0) == 3
):
cached["meta"] = {**cached.get("meta", {}), "cached": True}
return cached
if self.configured:
try:
payload = self._tushare_client().dragon_tiger(normalized_date)
except TushareError as exc:
return {
"meta": {
"requested_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
"trade_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
"source": "tushare_error",
"status": "error",
"schema_version": 3,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "龙虎榜数据暂不可用,请稍后重试。",
},
"summary": {
"trader_count": 0,
"identity_count": 0,
"operation_count": 0,
"active_stock_count": 0,
"seat_net_buy_million": 0,
"unclassified_count": 0,
"directory_count": 0,
},
"traders": [],
"unclassified_seats": [],
"rows": [],
}
payload["meta"]["cached"] = False
if payload.get("meta", {}).get("status") == "success":
self.database.save_data_snapshot(cache_kind, normalized_date, "tushare", payload)
return payload
return {
"meta": {
"requested_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
"trade_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
"source": "unavailable",
"status": "unavailable",
"schema_version": 3,
"cached": False,
"notice": "龙虎榜数据暂不可用,请联系管理员检查行情配置。",
},
"summary": {
"trader_count": 0,
"identity_count": 0,
"operation_count": 0,
"active_stock_count": 0,
"seat_net_buy_million": 0,
"unclassified_count": 0,
"directory_count": 0,
},
"traders": [],
"unclassified_seats": [],
"rows": [],
}
def _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
SERVICE = DashboardService()
class RequestHandler(
AccountHttpMixin,
SystemHttpMixin,
HttpTransportMixin,
BaseHTTPRequestHandler,
):
server_version = "XiaobaiReviewWeb/0.8"
application_service = SERVICE
route_registry = ROUTES
def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path == "/api/health":
self.send_json(
{
"ok": True,
"storage": "sqlite",
"account_required": True,
"time": datetime.now().astimezone().isoformat(timespec="seconds"),
}
)
return
if parsed.path == "/api/auth/me":
self.auth_me()
return
if parsed.path.startswith("/api/"):
if not self.require_auth():
return
if not self.require_access("GET", parsed.path):
return
if parsed.path == "/api/admin/settings":
self.send_json(
{"ok": True, **SERVICE.system_status(), "users": SERVICE.admin_users()}
)
return
if parsed.path == "/api/account/status":
self.send_json({"ok": True, **SERVICE.status()})
return
if parsed.path == "/api/alerts":
query = parse_qs(parsed.query)
try:
self.send_json(
SERVICE.alert_center(
query.get("status", ["all"])[0],
query.get("as_of", [date.today().isoformat()])[0],
)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/trades":
query = parse_qs(parsed.query)
try:
self.send_json(
SERVICE.trade_entries(
query.get("start_date", [""])[0],
query.get("end_date", [""])[0],
query.get("code", [""])[0],
)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/assistant/messages":
self.send_json({"items": SERVICE.assistant_messages()})
return
if parsed.path == "/api/dashboard":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
self.send_json(SERVICE.get_dashboard(trade_date, False))
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc:
self.send_json({"error": f"数据加载失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)
return
if parsed.path == "/api/auction":
query = parse_qs(parsed.query)
try:
self.send_json(
SERVICE.auction_center(
query.get("trade_date", [date.today().isoformat()])[0],
query.get("force", ["0"])[0] == "1",
)
)
except (ValueError, TushareError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/themes":
query = parse_qs(parsed.query)
try:
self.send_json(
SERVICE.theme_library(
query.get("trade_date", [date.today().isoformat()])[0],
query.get("force", ["0"])[0] == "1",
)
)
except (ValueError, TushareError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/themes/detail":
query = parse_qs(parsed.query)
try:
self.send_json(
SERVICE.theme_detail(
query.get("code", [""])[0],
query.get("trade_date", [date.today().isoformat()])[0],
)
)
except (ValueError, TushareError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/popularity":
query = parse_qs(parsed.query)
try:
self.send_json(
SERVICE.popularity(
query.get("trade_date", [date.today().isoformat()])[0],
query.get("force", ["0"])[0] == "1",
)
)
except (ValueError, TushareError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/realtime-aggregate/health":
query = parse_qs(parsed.query)
try:
self.send_json(
{
"ok": True,
"aggregate": SERVICE.realtime_aggregate_health(
query.get("sector", [""])[0]
),
}
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/sentiment/history":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
limit = int(query.get("limit", ["20"])[0])
self.send_json(SERVICE.sentiment_history(trade_date, limit))
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/rotation/history":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
self.send_json(SERVICE.rotation_history(trade_date, 9))
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/rotation/members":
query = parse_qs(parsed.query)
try:
self.send_json(
SERVICE.rotation_sector_members(
query.get("trade_date", [date.today().isoformat()])[0],
query.get("sector", [""])[0],
)
)
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/dragon-tiger":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
force = query.get("force", ["0"])[0] == "1"
try:
self.send_json(SERVICE.get_dragon_tiger(trade_date, force))
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/dragon-tiger/profiles":
query = parse_qs(parsed.query)
try:
self.send_json(
SERVICE.get_hot_money_profiles(
query.get("force", ["0"])[0] == "1"
)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/search":
query = parse_qs(parsed.query)
search_query = query.get("q", [""])[0]
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
self.send_json(SERVICE.search_entities(search_query, trade_date))
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/search/detail":
query = parse_qs(parsed.query)
entity_type = query.get("type", [""])[0]
identifier = query.get("id", [""])[0]
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
self.send_json(
SERVICE.get_search_detail(entity_type, identifier, trade_date)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except TushareError as exc:
self.send_json({"error": f"行情加载失败:{exc}"}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/chart/intraday":
query = parse_qs(parsed.query)
entity_type = query.get("type", [""])[0]
identifier = query.get("id", [""])[0]
try:
self.send_json(SERVICE.get_intraday_chart(entity_type, identifier))
except (ValueError, ChartDataError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
stock_preview_match = re.fullmatch(r"/api/stock/(\d{6})/preview", parsed.path)
if stock_preview_match:
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
force = query.get("force", ["0"])[0] == "1"
try:
self.send_json(
SERVICE.get_stock_preview(stock_preview_match.group(1), trade_date, force)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
stock_match = re.fullmatch(r"/api/stock/(\d{6})", parsed.path)
if stock_match:
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
force = query.get("force", ["0"])[0] == "1"
try:
self.send_json(SERVICE.get_stock_detail(stock_match.group(1), trade_date, force))
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/watchlist":
query = parse_qs(parsed.query)
try:
self.send_json(
SERVICE.review_watchlist(
query.get("trade_date", [date.today().isoformat()])[0]
)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/notes":
query = parse_qs(parsed.query)
code = query.get("code", [""])[0]
trade_date = query.get("trade_date", [""])[0].replace("-", "")
scope = query.get("scope", ["all"])[0]
if scope not in {"all", "daily", "stock"}:
self.send_json({"error": "复盘记录范围不支持。"}, HTTPStatus.BAD_REQUEST)
return
self.send_json(
{
"items": SERVICE.database.list_notes(
SERVICE.current_user_id, code, trade_date, scope
)
}
)
return
if parsed.path == "/api/seat-aliases":
self.send_json({"items": SERVICE.database.list_seat_aliases()})
return
if parsed.path == "/api/screener/setup":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
self.send_json(SERVICE.screener_setup(trade_date))
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/screener/tracking":
query = parse_qs(parsed.query)
try:
self.send_json(
SERVICE.screener_tracking(int(query.get("limit", ["12"])[0]))
)
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/mentors/setup":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
self.send_json(SERVICE.mentor_setup(trade_date))
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/mentors/messages":
query = parse_qs(parsed.query)
try:
self.send_json(
{
"items": SERVICE.mentor_messages(
query.get("mentor_id", [""])[0],
query.get("trade_date", [date.today().isoformat()])[0],
)
}
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/heaven/readings":
query = parse_qs(parsed.query)
try:
self.send_json(
SERVICE.heaven_readings(
query.get("mode", [""])[0],
query.get("context_date", [""])[0],
int(query.get("limit", ["100"])[0]),
)
)
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/heaven/setup":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
sector_name = query.get("sector", [""])[0]
stock_code = query.get("stock_code", [""])[0]
manual_data = None
manual_text = query.get("manual_data", [""])[0]
if manual_text:
try:
manual_data = json.loads(manual_text)
except json.JSONDecodeError:
self.send_json({"error": "六爻补录数据格式不正确。"}, HTTPStatus.BAD_REQUEST)
return
try:
self.send_json(
SERVICE.heaven_setup(
trade_date,
sector_name,
stock_code,
manual_data,
)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
self.serve_static(parsed.path)
def do_POST(self) -> None:
parsed = urlparse(self.path)
if parsed.path == "/api/auth/register":
self.auth_register()
return
if parsed.path == "/api/auth/login":
self.auth_login()
return
if not self.require_auth() or not self.require_csrf():
return
if not self.require_access("POST", parsed.path):
return
if parsed.path == "/api/auth/logout":
self.auth_logout()
return
if parsed.path == "/api/account/birth-profile":
self.save_birth_profile()
return
if parsed.path == "/api/account/password":
self.change_password()
return
alert_read_match = re.fullmatch(r"/api/alerts/(\d+)/read", parsed.path)
if alert_read_match:
self.send_json(
{"ok": True, **SERVICE.mark_alert_read(int(alert_read_match.group(1)))}
)
return
if parsed.path == "/api/alerts/read-all":
body = self.read_json_body(True)
self.send_json(
{"ok": True, **SERVICE.mark_all_alerts_read(str(body.get("as_of") or ""))}
)
return
if parsed.path == "/api/alerts":
self.save_alert()
return
if parsed.path == "/api/trades":
self.save_trade_entry()
return
if parsed.path == "/api/assistant/chat":
self.stream_assistant_chat()
return
if parsed.path == "/api/admin/settings":
self.save_system_settings()
return
if parsed.path == "/api/admin/settings/test":
self.test_system_llm_settings()
return
if parsed.path == "/api/admin/membership":
self.save_membership()
return
if parsed.path == "/api/admin/refresh":
self.start_background_refresh()
return
if parsed.path == "/api/watchlist":
self.save_watchlist()
return
if parsed.path == "/api/notes":
self.save_note()
return
if parsed.path == "/api/reasons":
self.save_reason()
return
if parsed.path == "/api/seat-aliases":
self.save_seat_alias()
return
if parsed.path == "/api/heaven/sector-phases":
self.save_sector_phase_override()
return
if parsed.path == "/api/backfill":
self.backfill_data()
return
if parsed.path == "/api/screener/sync":
self.sync_screener_data()
return
if parsed.path == "/api/screener/compile":
self.compile_screener_strategy()
return
if parsed.path == "/api/screener/strategies":
self.save_screener_strategy()
return
if parsed.path == "/api/screener/run":
self.run_screener()
return
if parsed.path == "/api/screener/tracking":
try:
result = SERVICE.add_screener_tracking(self.read_json_body())
self.send_json({"ok": True, **result})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/screener/tracking/refresh":
self.refresh_screener_tracking()
return
if parsed.path == "/api/mentors/preferences":
try:
result = SERVICE.save_mentor_preferences(self.read_json_body())
self.send_json({"ok": True, **result})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/mentors/chat":
self.stream_mentor_chat()
return
if parsed.path == "/api/heaven/hexagram":
self.heaven_hexagram()
return
if parsed.path == "/api/heaven/personal":
self.heaven_personal()
return
if parsed.path == "/api/heaven/interpret":
self.heaven_interpret()
return
self.send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND)
def do_DELETE(self) -> None:
parsed = urlparse(self.path)
if not self.require_auth() or not self.require_csrf():
return
if not self.require_access("DELETE", parsed.path):
return
if parsed.path == "/api/account/birth-profile":
deleted = SERVICE.database.delete_user_birth_profile(SERVICE.current_user_id)
self.send_json({"ok": True, "deleted": deleted})
return
if parsed.path == "/api/assistant/messages":
deleted = SERVICE.clear_assistant_messages()
self.send_json({"ok": True, "deleted": deleted})
return
if parsed.path == "/api/mentors/messages":
query = parse_qs(parsed.query)
try:
deleted = SERVICE.clear_mentor_messages(
query.get("mentor_id", [""])[0],
query.get("trade_date", [date.today().isoformat()])[0],
)
self.send_json({"ok": True, "deleted": deleted})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
strategy_match = re.fullmatch(r"/api/screener/strategies/(\d+)", parsed.path)
if strategy_match:
try:
result = SERVICE.delete_screener_strategy(int(strategy_match.group(1)))
self.send_json({"ok": True, **result})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
tracking_match = re.fullmatch(r"/api/screener/tracking/(\d+)", parsed.path)
if tracking_match:
result = SERVICE.remove_screener_tracking(int(tracking_match.group(1)))
self.send_json({"ok": True, **result})
return
watchlist_match = re.fullmatch(r"/api/watchlist/(\d{6})", parsed.path)
if watchlist_match:
deleted = SERVICE.database.delete_watchlist(
SERVICE.current_user_id, watchlist_match.group(1)
)
self.send_json({"ok": True, "deleted": deleted})
return
note_match = re.fullmatch(r"/api/notes/(\d+)", parsed.path)
if note_match:
deleted = SERVICE.database.delete_note(
SERVICE.current_user_id, int(note_match.group(1))
)
self.send_json({"ok": True, "deleted": deleted})
return
alert_match = re.fullmatch(r"/api/alerts/(\d+)", parsed.path)
if alert_match:
self.send_json(
{"ok": True, **SERVICE.delete_alert(int(alert_match.group(1)))}
)
return
trade_match = re.fullmatch(r"/api/trades/(\d+)", parsed.path)
if trade_match:
self.send_json(
{"ok": True, **SERVICE.delete_trade_entry(int(trade_match.group(1)))}
)
return
heaven_reading_match = re.fullmatch(r"/api/heaven/readings/(\d+)", parsed.path)
if heaven_reading_match:
deleted = SERVICE.database.delete_heaven_reading(
SERVICE.current_user_id, int(heaven_reading_match.group(1))
)
self.send_json({"ok": True, "deleted": deleted})
return
sector_phase_match = re.fullmatch(r"/api/heaven/sector-phases/(.+)", parsed.path)
if sector_phase_match:
name = unquote(sector_phase_match.group(1)).strip()
deleted = SERVICE.database.delete_sector_phase_override(name)
self.send_json({"ok": True, "deleted": deleted})
return
self.send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND)
def save_alert(self) -> None:
try:
body = self.read_json_body()
self.send_json({"ok": True, **SERVICE.create_alert(body)}, HTTPStatus.CREATED)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def save_trade_entry(self) -> None:
try:
body = self.read_json_body()
self.send_json({"ok": True, **SERVICE.save_trade_entry(body)}, HTTPStatus.CREATED)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def stream_assistant_chat(self) -> None:
try:
body = self.read_json_body()
stream = SERVICE.assistant_stream(body)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
self.send_header("Cache-Control", "no-cache, no-transform")
self.send_header("X-Accel-Buffering", "no")
self.send_header("Connection", "close")
self.end_headers()
try:
for chunk in stream:
self._write_stream_event({"type": "delta", "content": chunk})
self._write_stream_event({"type": "done"})
except (ValueError, ReviewAssistantError) as exc:
self._write_stream_event({"type": "error", "error": str(exc)})
except (BrokenPipeError, ConnectionResetError):
pass
finally:
self.close_connection = True
def _write_stream_event(self, payload: dict[str, Any]) -> None:
self.wfile.write(
(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
)
self.wfile.flush()
def save_llm_settings(self) -> None:
try:
body = self.read_json_body()
SERVICE.save_llm_settings(
body.get("primary") or {},
body.get("fallback") or {},
bool(body.get("fallback_enabled")),
)
self.send_json(
{
"ok": True,
"configured": SERVICE.llm_configured,
"model": SERVICE.llm_primary_model,
"fallback_configured": SERVICE.llm_fallback_configured,
"fallback_model": SERVICE.llm_fallback_model,
}
)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def save_llm_mode(self) -> None:
try:
body = self.read_json_body()
SERVICE.save_llm_mode(str(body.get("mode") or "auto"))
self.send_json({"ok": True, "llm_access": SERVICE.llm_access_status()})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def test_llm_settings(self) -> None:
try:
body = self.read_json_body()
role = str(body.get("role") or "")
profile = body.get("profile") or {}
result = SERVICE.test_llm_profile(role, profile)
self.send_json({"ok": True, "result": result})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def save_watchlist(self) -> None:
try:
body = self.read_json_body()
code = validate_stock_code(str(body.get("code", "")))
name = validate_text(body.get("name"), "股票名称", 30, required=True)
sector = validate_text(body.get("sector"), "所属板块", 50)
color = str(body.get("color") or "red")
if color not in {"red", "blue", "green", "amber"}:
raise ValueError("标记颜色不支持。")
remark = validate_text(body.get("remark"), "跟踪备注", 240)
SERVICE.database.save_watchlist(
SERVICE.current_user_id, code, name, sector, color, remark
)
self.send_json(
{
"ok": True,
"items": SERVICE.database.list_watchlist(SERVICE.current_user_id),
}
)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def save_note(self) -> None:
try:
body = self.read_json_body()
code = str(body.get("code") or "").strip()
if code:
code = validate_stock_code(code)
stock_name = validate_text(body.get("stock_name"), "股票名称", 30)
trade_date = normalize_date(str(body.get("trade_date") or date.today().isoformat()))
summary = validate_text(body.get("summary"), "盘面摘要", 500)
content = validate_text(body.get("content"), "复盘内容", 5000)
plan = validate_text(body.get("plan"), "明日计划", 2000)
if not summary and not content and not plan:
raise ValueError("每日复盘内容不能全部为空。")
raw_id = body.get("id")
note_id = int(raw_id) if raw_id else None
saved_id = SERVICE.database.save_note(
SERVICE.current_user_id,
code,
stock_name,
trade_date,
content,
plan,
note_id,
summary=summary,
)
self.send_json({"ok": True, "id": saved_id})
except (ValueError, TypeError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def save_reason(self) -> None:
try:
body = self.read_json_body()
SERVICE.save_reason(
str(body.get("trade_date") or ""),
str(body.get("code") or ""),
str(body.get("reason") or ""),
)
self.send_json({"ok": True})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def save_seat_alias(self) -> None:
try:
body = self.read_json_body()
seat_name = validate_text(body.get("seat_name"), "席位名称", 200, required=True)
alias = validate_text(body.get("alias"), "席位别名", 50, required=True)
SERVICE.database.save_seat_alias(seat_name, alias)
self.send_json({"ok": True})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def save_sector_phase_override(self) -> None:
try:
body = self.read_json_body()
name = validate_text(body.get("name"), "行业或题材名称", 50, required=True)
element = str(body.get("element") or "").strip()
if element not in {"木", "火", "土", "金", "水"}:
raise ValueError("五行归类必须是木、火、土、金或水。")
SERVICE.database.save_sector_phase_override(name, element)
self.send_json({"ok": True})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def backfill_data(self) -> None:
try:
body = self.read_json_body()
results = SERVICE.backfill(
str(body.get("start_date") or ""),
str(body.get("end_date") or ""),
)
self.send_json({"ok": True, "results": results})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc:
self.send_json({"error": f"历史回补失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)
def sync_screener_data(self) -> None:
try:
body = self.read_json_body()
result = SERVICE.sync_screener_data(
str(body.get("trade_date") or date.today().isoformat()),
int(body.get("lookback") or 45),
)
self.send_json({"ok": True, "result": result})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc:
self.send_json({"error": f"因子数据同步失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)
def compile_screener_strategy(self) -> None:
try:
body = self.read_json_body()
result = SERVICE.compile_screener_strategy(
str(body.get("prompt") or ""), str(body.get("regime") or "")
)
self.send_json({"ok": True, "strategy": result})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def save_screener_strategy(self) -> None:
try:
body = self.read_json_body()
result = SERVICE.save_screener_strategy(body)
self.send_json({"ok": True, **result})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def run_screener(self) -> None:
try:
body = self.read_json_body()
result = SERVICE.run_screener(body)
self.send_json({"ok": True, "result": result})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc:
self.send_json({"error": f"选股执行失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)
def refresh_screener_tracking(self) -> None:
try:
body = self.read_json_body(True)
trade_date = str(body.get("trade_date") or date.today().isoformat())
self.send_json({"ok": True, **SERVICE.refresh_screener_tracking(trade_date)})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc:
self.send_json({"error": f"跟踪刷新失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)
def stream_mentor_chat(self) -> None:
try:
body = self.read_json_body()
stream = SERVICE.mentor_stream(body)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
self.send_header("Cache-Control", "no-cache, no-transform")
self.send_header("X-Accel-Buffering", "no")
self.send_header("Connection", "close")
self.end_headers()
try:
for event in stream:
self._write_stream_event(event)
self._write_stream_event({"type": "done"})
except (ValueError, MentorAgentError) as exc:
self._write_stream_event({"type": "error", "error": str(exc)})
except (BrokenPipeError, ConnectionResetError):
pass
finally:
self.close_connection = True
def heaven_hexagram(self) -> None:
try:
body = self.read_json_body()
result = SERVICE.heaven_hexagram(body.get("lines"))
self.send_json({"ok": True, "hexagram": result})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def heaven_personal(self) -> None:
try:
body = self.read_json_body()
result = SERVICE.heaven_personal(body)
self.send_json({"ok": True, "personal": result})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def heaven_interpret(self) -> None:
try:
body = self.read_json_body()
result = SERVICE.heaven_interpret(body)
self.send_json({"ok": True, **result})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)