4334 lines
192 KiB
Python
4334 lines
192 KiB
Python
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)
|