fix(HEL-494): enforce datahub as sole website market boundary

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总管
2026-09-09 00:22:27 +08:00
co-authored by multica-agent
parent 8a7d1f3698
commit c9e2d30780
22 changed files with 212 additions and 216 deletions
+4 -4
View File
@@ -1,13 +1,13 @@
# Generated automatically when omitted. Back it up together with the database. # Generated automatically when omitted. Back it up together with the database.
APP_ENCRYPTION_KEY= APP_ENCRYPTION_KEY=
# Initial shared market-data credential. After first launch it is encrypted into # Market-source credentials are consumed and encrypted only by xiaobai-datahub.
# the system settings; all accounts use the same backend market snapshot. # compose.yaml masks them from the xiaobai-review website process.
TUSHARE_TOKEN=your_tushare_token_here TUSHARE_TOKEN=your_tushare_token_here
# Official xiaobai-datahub client. Read flags default on in config/datahub.config.json. # Official xiaobai-datahub client. Read flags default on in config/datahub.config.json.
# compose.yaml pins every DATAHUB_READ_* to 1 so leftover .env zeros cannot keep # compose.yaml pins every DATAHUB_READ_* to 1. The website has no provider
# official pages on the old APIs. Old website APIs are emergency fallback only. # fallback; source selection and failover happen inside xiaobai-datahub.
# DATAHUB_SHADOW_* can still override a single dataset. # DATAHUB_SHADOW_* can still override a single dataset.
DATAHUB_BASE_URL=http://127.0.0.1:8766 DATAHUB_BASE_URL=http://127.0.0.1:8766
DATAHUB_TOKEN= DATAHUB_TOKEN=
+8 -3
View File
@@ -38,10 +38,15 @@ background scheduler
fields, and feature-specific exceptions belong to `backend/features/<feature>/routes.py`. fields, and feature-specific exceptions belong to `backend/features/<feature>/routes.py`.
- `backend/features/<feature>/` owns the mechanically moved service, repository, HTTP, agent, - `backend/features/<feature>/` owns the mechanically moved service, repository, HTTP, agent,
or deterministic calculation code for that product area. or deterministic calculation code for that product area.
- `backend/data/` owns provider construction, source policy, provenance, units, freshness, - `backend/data/` owns the website-side DataHub client, stable dataset contracts, provenance,
coverage, display-versus-calculation eligibility, and shared numeric normalization policies. units, freshness, coverage, display-versus-calculation eligibility, and shared numeric
normalization policies. The website process does not construct or configure external market
providers; provider credentials, source selection, retries, fallbacks, caching, and backfill
belong exclusively to the `xiaobai-datahub` service.
- `backend/data/providers/tushare_client.py` is the stable public `TushareClient` facade and - `backend/data/providers/tushare_client.py` is the stable public `TushareClient` facade and
owns only its dataclass fields and shared cache state. Tushare HTTP transport belongs to is retained as the dataset-contract compatibility surface and isolated test facade. Production
website services never instantiate it directly: its query methods are served by the DataHub
proxy. Its split modules document the stable contract: Tushare HTTP transport belongs to
`tushare_transport.py`; market overview and realtime breadth belong to `tushare_transport.py`; market overview and realtime breadth belong to
`tushare_dashboard.py`; indices belong to `tushare_indices.py`; Shenwan membership and `tushare_dashboard.py`; indices belong to `tushare_indices.py`; Shenwan membership and
industry snapshots belong to `tushare_industries.py`; generic sector snapshots belong to industry snapshots belong to `tushare_industries.py`; generic sector snapshots belong to
+7 -4
View File
@@ -22,9 +22,10 @@ xiaobai-review 容器 :8765
`-- heaven_knowledge.json 优先读取;缺失时回退到上方 seed `-- heaven_knowledge.json 优先读取;缺失时回退到上方 seed
``` ```
账号、加密后的公共数据 Token、平台模型 API Key、生辰资料、行情快照和复盘数据均在 账号、平台模型 API Key、生辰资料、行情快照和复盘数据均在 `data/review.db`。外部行情源
`data/review.db`。解密密钥来自 `.env` 中的 `APP_ENCRYPTION_KEY`。数据库与 Token 只允许保存在 `xiaobai-datahub` 的环境或凭据库,网站进程不读取、不保存,也不向
密钥必须成对备份,任意一个丢失都无法恢复账号内的加密资料。 提供方直接发请求。解密密钥来自 `.env` 中的 `APP_ENCRYPTION_KEY`。数据库与密钥必须成对
备份,任意一个丢失都无法恢复账号内的加密资料。
问天静态知识文件: 问天静态知识文件:
@@ -41,7 +42,9 @@ xiaobai-review 容器 :8765
挂载进入容器,但被 Git 与 Docker 构建上下文排除,不会进入 Gitea 或镜像。私有 Skill 挂载进入容器,但被 Git 与 Docker 构建上下文排除,不会进入 Gitea 或镜像。私有 Skill
只对管理员账号返回和开放调用,也会随本指南的 `data` 备份一起保存。 只对管理员账号返回和开放调用,也会随本指南的 `data` 备份一起保存。
首个注册账号自动成为管理员。管理员在“系统管理”中配置全站共享行情、后台刷新、平台会员模型及手动会员;普通用户的“账号设置”用于个人资料、会员状态、修改密码和切换账号。后台行情更新不会主动刷新任何浏览器页面。 首个注册账号自动成为管理员。管理员在网站“系统管理”中查看数据中枢状态并配置后台刷新、
平台会员模型及手动会员;行情源凭据和调度策略在数据中枢后台统一管理。普通用户的“账号设置”
用于个人资料、会员状态、修改密码和切换账号。后台行情更新不会主动刷新任何浏览器页面。
## 2. 服务器要求 ## 2. 服务器要求
-21
View File
@@ -60,22 +60,6 @@ from backend.llm.service import LLMServiceMixin
from database import ReviewDatabase from database import ReviewDatabase
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",
}
class DashboardService( class DashboardService(
SystemServiceMixin, SystemServiceMixin,
AccountApplicationMixin, AccountApplicationMixin,
@@ -121,14 +105,9 @@ class DashboardService(
self._system_credentials, self._system_credentials,
MENTOR_SKILLS_DIR, MENTOR_SKILLS_DIR,
PRIVATE_MENTOR_SKILLS_DIR, PRIVATE_MENTOR_SKILLS_DIR,
lambda: self.token,
) )
self.data_gateway = self.container.data_gateway self.data_gateway = self.container.data_gateway
self.ifind = self.container.ifind self.ifind = self.container.ifind
refresh = str(self._system_credentials.get("ifind_refresh_token") or "")
access = str(self._system_credentials.get("ifind_access_token") or "")
if refresh or access:
self.ifind.set_credentials(refresh, access)
self.screener = self.container.screener self.screener = self.container.screener
self.strategy_tracking = self.container.strategy_tracking self.strategy_tracking = self.container.strategy_tracking
self.alert_service = self.container.alert_service self.alert_service = self.container.alert_service
+1 -3
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from collections.abc import Callable
from backend.data import DataGateway, build_data_gateway from backend.data import DataGateway, build_data_gateway
from backend.database.repositories import RepositoryBundle, build_repository_bundle from backend.database.repositories import RepositoryBundle, build_repository_bundle
@@ -39,9 +38,8 @@ def build_application_container(
credentials: dict[str, object], credentials: dict[str, object],
mentor_skills_dir: Path, mentor_skills_dir: Path,
private_mentor_skills_dir: Path, private_mentor_skills_dir: Path,
tushare_token_supplier: Callable[[], str] | None = None,
) -> ApplicationContainer: ) -> ApplicationContainer:
data_gateway = build_data_gateway(credentials, tushare_token_supplier) data_gateway = build_data_gateway(credentials)
repositories = build_repository_bundle(database) repositories = build_repository_bundle(database)
jobs = InProcessJobRunner(JobRegistry.load(), SQLiteJobRunRepository(database)) jobs = InProcessJobRunner(JobRegistry.load(), SQLiteJobRunRepository(database))
return ApplicationContainer( return ApplicationContainer(
-3
View File
@@ -10,11 +10,8 @@ from backend.features.accounts.security import SecretVault
def environment_credentials(environment: Mapping[str, str]) -> dict[str, str]: def environment_credentials(environment: Mapping[str, str]) -> dict[str, str]:
return { return {
"tushare_token": str(environment.get("TUSHARE_TOKEN") or "").strip(),
"datahub_token": str(environment.get("DATAHUB_TOKEN") or "").strip(), "datahub_token": str(environment.get("DATAHUB_TOKEN") or "").strip(),
"datahub_base_url": str(environment.get("DATAHUB_BASE_URL") or "").strip(), "datahub_base_url": str(environment.get("DATAHUB_BASE_URL") or "").strip(),
"ifind_refresh_token": str(environment.get("IFIND_REFRESH_TOKEN") or "").strip(),
"ifind_access_token": str(environment.get("IFIND_ACCESS_TOKEN") or "").strip(),
"platform_llm_primary_api_key": str( "platform_llm_primary_api_key": str(
environment.get("LLM_PRIMARY_API_KEY") or environment.get("LLM_API_KEY") or "" environment.get("LLM_PRIMARY_API_KEY") or environment.get("LLM_API_KEY") or ""
).strip(), ).strip(),
-9
View File
@@ -96,15 +96,6 @@ class DatahubClient:
{"api_name": api_name, "params": params or {}, "fields": fields}, {"api_name": api_name, "params": params or {}, "fields": fields},
) )
def put_ifind_credentials(self, refresh_token: str, access_token: str = "") -> DatahubResponse:
return self.post(
"/v1/credentials/ifind",
{
"ifind_refresh_token": refresh_token,
"ifind_access_token": access_token,
},
)
def sector_quote(self, code: str, date: str = "") -> DatahubResponse: def sector_quote(self, code: str, date: str = "") -> DatahubResponse:
payload: dict[str, Any] = {"code": code} payload: dict[str, Any] = {"code": code}
if date: if date:
-23
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import logging
import time import time
from typing import Any from typing import Any
@@ -8,9 +7,6 @@ from backend.data.datahub.bridge import DatahubBridge
from backend.data.datahub.errors import DatahubError from backend.data.datahub.errors import DatahubError
from backend.data.providers.ifind_client import IfindError from backend.data.providers.ifind_client import IfindError
LOGGER = logging.getLogger("xiaobai.datahub")
class HubIfindProxy: class HubIfindProxy:
"""Website-facing iFinD facade. Talks only to xiaobai-datahub.""" """Website-facing iFinD facade. Talks only to xiaobai-datahub."""
@@ -18,20 +14,12 @@ class HubIfindProxy:
self._datahub = datahub self._datahub = datahub
self._status: dict[str, Any] | None = None self._status: dict[str, Any] | None = None
self._status_at = 0.0 self._status_at = 0.0
self._pending: tuple[str, str] | None = None
@property @property
def configured(self) -> bool: def configured(self) -> bool:
return bool(self.status().get("configured")) return bool(self.status().get("configured"))
def set_credentials(self, refresh_token: str, access_token: str = "") -> None:
self._pending = (str(refresh_token or ""), str(access_token or ""))
self._status = None
self._status_at = 0.0
self._flush_credentials()
def status(self) -> dict[str, Any]: def status(self) -> dict[str, Any]:
self._flush_credentials()
now = time.monotonic() now = time.monotonic()
if self._status is not None and now - self._status_at < 30: if self._status is not None and now - self._status_at < 30:
return dict(self._status) return dict(self._status)
@@ -139,18 +127,7 @@ class HubIfindProxy:
"sample_time": str(payload[0].get("time") or "") if payload else "", "sample_time": str(payload[0].get("time") or "") if payload else "",
} }
def _flush_credentials(self) -> None:
pending = self._pending
if pending is None or not self._datahub.settings.token:
return
try:
self._datahub.client.put_ifind_credentials(pending[0], pending[1])
self._pending = None
except DatahubError:
LOGGER.warning("datahub ifind credential push failed; will retry")
def _rows(self, api_name: str, params: dict[str, Any]) -> list[dict[str, Any]]: def _rows(self, api_name: str, params: dict[str, Any]) -> list[dict[str, Any]]:
self._flush_credentials()
try: try:
response = self._datahub.client.query_api(api_name, params) response = self._datahub.client.query_api(api_name, params)
except DatahubError as exc: except DatahubError as exc:
-4
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -95,11 +94,8 @@ class DataGateway:
def build_data_gateway( def build_data_gateway(
credentials: dict[str, object], credentials: dict[str, object],
tushare_token_supplier: Callable[[], str] | None = None,
datahub_settings: DatahubSettings | None = None, datahub_settings: DatahubSettings | None = None,
) -> DataGateway: ) -> DataGateway:
# Website Tushare tokens are not used to assemble market clients.
del tushare_token_supplier
policy = DataSourcePolicy.load() policy = DataSourcePolicy.load()
settings = datahub_settings or DatahubSettings.load(credentials=credentials) settings = datahub_settings or DatahubSettings.load(credentials=credentials)
datahub_client = DatahubClient(settings) datahub_client = DatahubClient(settings)
+11 -14
View File
@@ -14,7 +14,7 @@ from backend.bootstrap.config import (
validate_text, validate_text,
) )
from backend.data.providers.ifind_client import IfindError from backend.data.providers.ifind_client import IfindError
from backend.data.providers.tushare_client import TushareClient, TushareError from backend.data.providers.tushare_client import TushareError
from backend.data.providers.tushare_helpers import _moneyflow_payload, _optional_number from backend.data.providers.tushare_helpers import _moneyflow_payload, _optional_number
from backend.data.realtime import RealtimeAggregateError from backend.data.realtime import RealtimeAggregateError
from backend.features.market.backfill_history import ( from backend.features.market.backfill_history import (
@@ -61,16 +61,14 @@ class MarketServiceMixin:
self._tushare_client(), self._tushare_client(),
ifind=self.ifind, ifind=self.ifind,
) )
def _tushare_client(self) -> TushareClient: def _tushare_client(self) -> Any:
override = getattr(self, "_market_client_override", None)
if override is not None:
return override
gateway = getattr(self, "data_gateway", None) gateway = getattr(self, "data_gateway", None)
if gateway is not None: if gateway is None:
return gateway.tushare() raise RuntimeError("数据中枢尚未装配。")
# Compatibility for isolated legacy unit-test service stubs. return gateway.tushare()
client = TushareClient(self.token)
aggregator = getattr(self, "realtime_aggregator", None)
if aggregator is not None:
client.realtime_aggregator = aggregator
return client
def _now(self) -> datetime: def _now(self) -> datetime:
clock = getattr(self, "clock", None) clock = getattr(self, "clock", None)
@@ -178,11 +176,10 @@ class MarketServiceMixin:
return "最近可用交易日" return "最近可用交易日"
return f"{int(compact[4:6])}{int(compact[6:8])}" return f"{int(compact[4:6])}{int(compact[6:8])}"
@classmethod def _preparing_display_notice(self, actual_date: str, requested_date: str) -> str:
def _preparing_display_notice(cls, actual_date: str, requested_date: str) -> str: shown = self._chinese_month_day(actual_date)
shown = cls._chinese_month_day(actual_date)
requested = str(requested_date or "").replace("-", "") requested = str(requested_date or "").replace("-", "")
if requested == date.today().strftime("%Y%m%d"): if requested == self._now().strftime("%Y%m%d"):
return f"今日数据正在准备,当前展示 {shown}" return f"今日数据正在准备,当前展示 {shown}"
return f"所选日期数据尚未到齐,当前展示 {shown}" return f"所选日期数据尚未到齐,当前展示 {shown}"
+25 -31
View File
@@ -4,7 +4,14 @@ import re
import secrets import secrets
from typing import Any from typing import Any
from backend.bootstrap.config import TOKEN_PATTERN, validate_text from backend.bootstrap.config import validate_text
MARKET_SOURCE_SECRET_KEYS = {
"tushare_token",
"ifind_refresh_token",
"ifind_access_token",
}
class SystemServiceMixin: class SystemServiceMixin:
@@ -18,9 +25,6 @@ class SystemServiceMixin:
first_encrypted = self.database.get_user_credentials(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 {} first_personal = self.vault.decrypt_json(first_encrypted) if first_encrypted else {}
defaults = { 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_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_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_primary_model": environment.get("platform_llm_primary_model") or first_personal.get("llm_primary_model") or "",
@@ -34,6 +38,10 @@ class SystemServiceMixin:
if key not in current: if key not in current:
current[key] = value current[key] = value
changed = True changed = True
for key in MARKET_SOURCE_SECRET_KEYS:
if key in current:
current.pop(key, None)
changed = True
if not isinstance(current.get("llm_models"), list): if not isinstance(current.get("llm_models"), list):
migrated_models: list[dict[str, str]] = [] migrated_models: list[dict[str, str]] = []
for role, label in (("primary", "原主模型"), ("fallback", "原辅助模型")): for role, label in (("primary", "原主模型"), ("fallback", "原辅助模型")):
@@ -56,26 +64,27 @@ class SystemServiceMixin:
self.database.save_system_setting("credentials", self.vault.encrypt_json(current)) self.database.save_system_setting("credentials", self.vault.encrypt_json(current))
for row in self.database.list_user_credentials(): for row in self.database.list_user_credentials():
personal = self.vault.decrypt_json(str(row.get("encrypted_payload") or "")) personal = self.vault.decrypt_json(str(row.get("encrypted_payload") or ""))
if "tushare_token" in personal: if any(key in personal for key in MARKET_SOURCE_SECRET_KEYS):
personal.pop("tushare_token", None) for key in MARKET_SOURCE_SECRET_KEYS:
personal.pop(key, None)
self.database.save_user_credentials( self.database.save_user_credentials(
int(row["user_id"]), self.vault.encrypt_json(personal) int(row["user_id"]), self.vault.encrypt_json(personal)
) )
return current return current
def _save_system_credentials(self, credentials: dict[str, Any]) -> None: def _save_system_credentials(self, credentials: dict[str, Any]) -> None:
sanitized = {
key: value
for key, value in credentials.items()
if key not in MARKET_SOURCE_SECRET_KEYS
}
with self.system_lock: with self.system_lock:
self.database.save_system_setting("credentials", self.vault.encrypt_json(credentials)) self.database.save_system_setting("credentials", self.vault.encrypt_json(sanitized))
self._system_credentials = dict(credentials) self._system_credentials = dict(sanitized)
if hasattr(self, "ifind"):
self.ifind.set_credentials(
str(credentials.get("ifind_refresh_token") or ""),
str(credentials.get("ifind_access_token") or ""),
)
@property @property
def configured(self) -> bool: def configured(self) -> bool:
return bool(self.token) return bool(self._datahub_status().get("configured"))
def _credentials(self) -> dict[str, str]: def _credentials(self) -> dict[str, str]:
credentials = getattr(self._request_context, "credentials", {}) credentials = getattr(self._request_context, "credentials", {})
@@ -99,7 +108,7 @@ class SystemServiceMixin:
@property @property
def token(self) -> str: def token(self) -> str:
return str(self._system_credentials.get("tushare_token") or "") return "datahub" if self.configured else ""
def system_status(self) -> dict[str, Any]: def system_status(self) -> dict[str, Any]:
platform = self._platform_llm_profile() platform = self._platform_llm_profile()
@@ -164,19 +173,6 @@ class SystemServiceMixin:
def save_system_settings(self, payload: dict[str, Any]) -> dict[str, Any]: def save_system_settings(self, payload: dict[str, Any]) -> dict[str, Any]:
current = dict(self._system_credentials) 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 = { existing_models = {
str(item.get("id") or ""): item str(item.get("id") or ""): item
for item in current.get("llm_models") or [] for item in current.get("llm_models") or []
@@ -238,8 +234,6 @@ class SystemServiceMixin:
raise ValueError("会员每日额度应为 1 至 1000。") from exc raise ValueError("会员每日额度应为 1 至 1000。") from exc
current.update( current.update(
{ {
"tushare_token": token,
"ifind_refresh_token": ifind_refresh_token,
"llm_models": models, "llm_models": models,
"primary_model_id": primary_model_id, "primary_model_id": primary_model_id,
"fallback_model_id": fallback_model_id, "fallback_model_id": fallback_model_id,
@@ -259,7 +253,7 @@ class SystemServiceMixin:
llm_access = self.llm_access_status() llm_access = self.llm_access_status()
return { return {
"configured": self.configured, "configured": self.configured,
"mode": "tushare" if self.configured else "unavailable", "mode": "datahub" if self.configured else "unavailable",
"llm_configured": self.llm_configured, "llm_configured": self.llm_configured,
"llm_model": self.llm_primary_model if self.llm_configured else "", "llm_model": self.llm_primary_model if self.llm_configured else "",
"llm_fallback_configured": self.llm_fallback_configured, "llm_fallback_configured": self.llm_fallback_configured,
+4
View File
@@ -13,6 +13,10 @@ services:
- ./.env - ./.env
environment: environment:
APP_ENCRYPTION_KEY: "${APP_ENCRYPTION_KEY:?APP_ENCRYPTION_KEY must be set in .env}" APP_ENCRYPTION_KEY: "${APP_ENCRYPTION_KEY:?APP_ENCRYPTION_KEY must be set in .env}"
# Provider credentials are consumed only by xiaobai-datahub.
TUSHARE_TOKEN: ""
IFIND_REFRESH_TOKEN: ""
IFIND_ACCESS_TOKEN: ""
DATAHUB_BASE_URL: "${DATAHUB_BASE_URL:-http://192.168.200.11:8766}" DATAHUB_BASE_URL: "${DATAHUB_BASE_URL:-http://192.168.200.11:8766}"
DATAHUB_READ_CALENDAR: "1" DATAHUB_READ_CALENDAR: "1"
DATAHUB_READ_STOCKS: "1" DATAHUB_READ_STOCKS: "1"
+16 -16
View File
@@ -478,7 +478,7 @@
}, },
{ {
"path": "frontend/index.html", "path": "frontend/index.html",
"bytes": 48447, "bytes": 48403,
"lines": 665 "lines": 665
}, },
{ {
@@ -546,11 +546,6 @@
"bytes": 15743, "bytes": 15743,
"lines": 401 "lines": 401
}, },
{
"path": "frontend/shared/admin.js",
"bytes": 15235,
"lines": 289
},
{ {
"path": "frontend/shared/dashboard.js", "path": "frontend/shared/dashboard.js",
"bytes": 15063, "bytes": 15063,
@@ -561,6 +556,11 @@
"bytes": 14942, "bytes": 14942,
"lines": 235 "lines": 235
}, },
{
"path": "frontend/shared/admin.js",
"bytes": 14836,
"lines": 283
},
{ {
"path": "backend/features/screener/data_sync.py", "path": "backend/features/screener/data_sync.py",
"bytes": 14743, "bytes": 14743,
@@ -576,16 +576,16 @@
"bytes": 13219, "bytes": 13219,
"lines": 289 "lines": 289
}, },
{
"path": "backend/features/system/service.py",
"bytes": 12937,
"lines": 271
},
{ {
"path": "backend/features/market/insights_auction_data.py", "path": "backend/features/market/insights_auction_data.py",
"bytes": 12829, "bytes": 12829,
"lines": 318 "lines": 318
}, },
{
"path": "backend/features/system/service.py",
"bytes": 12180,
"lines": 265
},
{ {
"path": "backend/features/market/insights_auction.py", "path": "backend/features/market/insights_auction.py",
"bytes": 10717, "bytes": 10717,
@@ -636,11 +636,6 @@
"bytes": 8357, "bytes": 8357,
"lines": 116 "lines": 116
}, },
{
"path": "backend/application.py",
"bytes": 6997,
"lines": 182
},
{ {
"path": "backend/features/screener/formula.py", "path": "backend/features/screener/formula.py",
"bytes": 6983, "bytes": 6983,
@@ -661,6 +656,11 @@
"bytes": 6488, "bytes": 6488,
"lines": 81 "lines": 81
}, },
{
"path": "backend/application.py",
"bytes": 6399,
"lines": 161
},
{ {
"path": "frontend/pages/market/stock-detail.js", "path": "frontend/pages/market/stock-detail.js",
"bytes": 6325, "bytes": 6325,
@@ -320,6 +320,11 @@ PC端统一采用以下固定骨架:
### 6.1 数据源职责 ### 6.1 数据源职责
运行边界:下表中的职责全部由独立的 `xiaobai-datahub` 数据中枢执行。主网站只按固定业务
协议请求“行情、日K、分时、申万、竞价”等数据,不接触任何提供方参数或凭据,也不决定优先级、
重试、降级和回填。数据中枢是主网站唯一的行情出口;中枢暂时取不到新数据时,网站只能读取
已经归档的真实快照,不能绕回旧提供方直连接口。
| 数据源 | 可用于正式计算 | 主要职责 | | 数据源 | 可用于正式计算 | 主要职责 |
|---|:---:|---| |---|:---:|---|
| Tushare | 是 | 交易日历、股票主表、日线、估值、财务、资金流、申万行业、涨跌停、9:25竞价、热榜、龙虎榜 | | Tushare | 是 | 交易日历、股票主表、日线、估值、财务、资金流、申万行业、涨跌停、9:25竞价、热榜、龙虎榜 |
+3 -3
View File
@@ -607,13 +607,13 @@
<div class="admin-panel" data-admin-panel="market"> <div class="admin-panel" data-admin-panel="market">
<form id="systemMarketForm" class="settings-section"> <form id="systemMarketForm" class="settings-section">
<div class="settings-section-heading"><h3>公共行情</h3><span id="systemDataStatus">待检查</span></div> <div class="settings-section-heading"><h3>公共行情</h3><span id="systemDataStatus">待检查</span></div>
<label class="form-field"><span>Tushare Token</span><input id="systemTokenInput" type="password" autocomplete="off" minlength="20" placeholder="留空保留现有 Token"></label> <label class="form-field"><span>行情来源凭据</span><input id="systemTokenInput" type="text" value="请在数据中枢后台统一管理" disabled></label>
<label class="form-field"><span>iFinD Refresh Token</span><input id="systemIfindTokenInput" type="password" autocomplete="off" maxlength="2048" placeholder="留空保留现有 Token"></label> <label class="form-field"><span>实时来源凭据</span><input id="systemIfindTokenInput" type="text" value="请在数据中枢后台统一管理" disabled></label>
<label class="switch-control"><input id="systemBackgroundRefresh" type="checkbox"><span>启用交易时段后台刷新</span></label> <label class="switch-control"><input id="systemBackgroundRefresh" type="checkbox"><span>启用交易时段后台刷新</span></label>
<p class="form-hint">所有用户读取同一份后台快照,页面不会随后台任务自动重绘。</p> <p class="form-hint">所有用户读取同一份后台快照,页面不会随后台任务自动重绘。</p>
<div id="datahubRouteStatus" class="admin-refresh-status" data-tone="idle" role="status" aria-live="polite"><i data-lucide="database"></i><span>数据中枢线路待检查</span></div> <div id="datahubRouteStatus" class="admin-refresh-status" data-tone="idle" role="status" aria-live="polite"><i data-lucide="database"></i><span>数据中枢线路待检查</span></div>
<div id="adminRefreshStatus" class="admin-refresh-status" data-tone="idle" role="status" aria-live="polite"><i data-lucide="circle-dot"></i><span>尚未手动刷新</span></div> <div id="adminRefreshStatus" class="admin-refresh-status" data-tone="idle" role="status" aria-live="polite"><i data-lucide="circle-dot"></i><span>尚未手动刷新</span></div>
<div class="dialog-actions admin-inline-actions"><button id="adminRefreshButton" class="button" type="button"><i data-lucide="refresh-cw"></i>立即后台刷新</button><button class="button primary" type="submit">保存行情配</button></div> <div class="dialog-actions admin-inline-actions"><button id="adminRefreshButton" class="button" type="button"><i data-lucide="refresh-cw"></i>立即后台刷新</button><button class="button primary" type="submit">保存刷新设</button></div>
</form> </form>
<section class="settings-section"> <section class="settings-section">
<div class="settings-section-heading"><h3>历史数据回补</h3><span>管理员任务</span></div> <div class="settings-section-heading"><h3>历史数据回补</h3><span>管理员任务</span></div>
+7 -11
View File
@@ -5242,16 +5242,16 @@
'<div class="m-sys-body" data-system-admin-panel="market">' + '<div class="m-sys-body" data-system-admin-panel="market">' +
'<div class="m-card m-sys-section"><strong>数据源状态</strong>' + '<div class="m-card m-sys-section"><strong>数据源状态</strong>' +
'<div class="m-sys-status-list">' + '<div class="m-sys-status-list">' +
'<div class="m-sys-status-item"><span>Tushare</span><span>' + statusDot(data.configured) + (data.configured ? " 已配置" : " 未配置") + "</span></div>" + '<div class="m-sys-status-item"><span>数据中枢</span><span>' + statusDot(data.configured) + (data.configured ? " 已连接" : " 未连接") + "</span></div>" +
'<div class="m-sys-status-item"><span>iFinD</span><span>' + statusDot(ifind.configured) + (ifind.configured ? " 已配置" : " 未配置") + "</span></div>" + '<div class="m-sys-status-item"><span>iFinD</span><span>' + statusDot(ifind.configured) + (ifind.configured ? " 已配置" : " 未配置") + "</span></div>" +
'<div class="m-sys-status-item"><span>行情快照</span><strong>' + number(data.snapshot_dates) + " 个交易日</strong></div>" + '<div class="m-sys-status-item"><span>行情快照</span><strong>' + number(data.snapshot_dates) + " 个交易日</strong></div>" +
'<div class="m-sys-status-item"><span>后台刷新</span><span>' + statusDot(data.background_refresh_enabled) + (data.background_refresh_enabled ? " 已启用" : " 已暂停") + "</span></div>" + '<div class="m-sys-status-item"><span>后台刷新</span><span>' + statusDot(data.background_refresh_enabled) + (data.background_refresh_enabled ? " 已启用" : " 已暂停") + "</span></div>" +
'<div class="m-sys-status-item"><span>数据中枢</span><span>' + statusDot(Boolean((data.datahub || {}).configured) && !((data.datahub || {}).fallback_count)) + datahubStatusText(data.datahub || {}) + "</span></div>" + '<div class="m-sys-status-item"><span>数据中枢</span><span>' + statusDot(Boolean((data.datahub || {}).configured) && !((data.datahub || {}).fallback_count)) + datahubStatusText(data.datahub || {}) + "</span></div>" +
"</div></div>" + "</div></div>" +
'<div class="m-card m-sys-section"><strong>数据源密钥</strong>' + '<div class="m-card m-sys-section"><strong>数据源密钥</strong>' +
formFieldHtml("Tushare Token", '<input id="m-sys-token" type="password" autocomplete="off" minlength="20" placeholder="留空则保留现有 Token">', false) + formFieldHtml("行情来源凭据", '<input id="m-sys-token" type="text" value="请在数据中枢后台统一管理" disabled>', false) +
formFieldHtml("iFinD Refresh Token", '<input id="m-sys-ifind" type="password" autocomplete="off" maxlength="2048" placeholder="留空则保留现有 Token">', false) + formFieldHtml("实时来源凭据", '<input id="m-sys-ifind" type="text" value="请在数据中枢后台统一管理" disabled>', false) +
'<button class="m-btn-primary" type="button" data-system-save-market>保存密钥</button></div>' + '<button class="m-btn-primary" type="button" data-system-save-market>刷新状态</button></div>' +
'<div class="m-card m-sys-section"><strong>后台刷新</strong>' + '<div class="m-card m-sys-section"><strong>后台刷新</strong>' +
'<div class="m-sys-switch-row"><div><strong>交易时段自动刷新</strong><p class="m-sys-hint">开启后后台定时更新快照</p></div>' + '<div class="m-sys-switch-row"><div><strong>交易时段自动刷新</strong><p class="m-sys-hint">开启后后台定时更新快照</p></div>' +
'<button class="m-theme-switch" type="button" data-system-toggle-refresh role="switch" aria-checked="' + (data.background_refresh_enabled ? "true" : "false") + '" aria-label="交易时段自动刷新"><span class="m-theme-switch-thumb"></span></button></div>' + '<button class="m-theme-switch" type="button" data-system-toggle-refresh role="switch" aria-checked="' + (data.background_refresh_enabled ? "true" : "false") + '" aria-label="交易时段自动刷新"><span class="m-theme-switch-thumb"></span></button></div>' +
@@ -5550,14 +5550,10 @@
function saveSystemMarket() { function saveSystemMarket() {
const button = document.querySelector("[data-system-save-market]"); const button = document.querySelector("[data-system-save-market]");
if (button) button.disabled = true; if (button) button.disabled = true;
global.MobileAPI.request("/api/admin/settings", "POST", { loadSystem().then(function () {
tushare_token: ((document.getElementById("m-sys-token") || {}).value || "").trim(), showToast("数据中枢状态已刷新");
ifind_refresh_token: ((document.getElementById("m-sys-ifind") || {}).value || "").trim(),
}).then(function () {
showToast("行情密钥已保存");
loadSystem();
}).catch(function (error) { }).catch(function (error) {
showToast(error && error.message ? error.message : "系统配置保存失败"); showToast(error && error.message ? error.message : "数据中枢状态读取失败");
}).then(function () { }).then(function () {
if (button) button.disabled = false; if (button) button.disabled = false;
}); });
+3 -9
View File
@@ -41,12 +41,10 @@ async function openAdminSettings(refreshOnly = false) {
const ifind = data.ifind || {}; const ifind = data.ifind || {};
const llm = payload.llm || {}; const llm = payload.llm || {};
const membership = payload.membership || {}; const membership = payload.membership || {};
status.textContent = `Tushare ${data.configured ? "已配置" : "未配置"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`; status.textContent = `数据中枢 ${data.configured ? "已连接" : "未连接"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`;
status.classList.toggle("connected", Boolean(data.configured)); status.classList.toggle("connected", Boolean(data.configured));
setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停"); setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停");
renderDatahubRouteStatus(data.datahub || {}); renderDatahubRouteStatus(data.datahub || {});
document.querySelector("#systemTokenInput").value = "";
document.querySelector("#systemIfindTokenInput").value = "";
document.querySelector("#systemBackgroundRefresh").checked = Boolean(data.background_refresh_enabled); document.querySelector("#systemBackgroundRefresh").checked = Boolean(data.background_refresh_enabled);
document.querySelector("#memberDailyLimit").value = number(membership.member_daily_limit) || 50; document.querySelector("#memberDailyLimit").value = number(membership.member_daily_limit) || 50;
renderModelPool(llm.models || [], llm.primary_model_id || "", llm.fallback_model_id || ""); renderModelPool(llm.models || [], llm.primary_model_id || "", llm.fallback_model_id || "");
@@ -72,7 +70,7 @@ function renderDatahubRouteStatus(hub) {
if (label) { if (label) {
label.textContent = hub.configured label.textContent = hub.configured
? `数据中枢主线路 ${enabled}/${total},当前无备用` ? `数据中枢主线路 ${enabled}/${total},当前无备用`
: "数据中枢未配置,网站仍走原接口"; : "数据中枢未配置,网站只保留已有真实快照";
} }
} }
@@ -205,13 +203,9 @@ async function saveMarketSettings(event) {
button.disabled = true; button.disabled = true;
try { try {
await apiRequest("/api/admin/settings", "POST", { await apiRequest("/api/admin/settings", "POST", {
tushare_token: document.querySelector("#systemTokenInput").value.trim(),
ifind_refresh_token: document.querySelector("#systemIfindTokenInput").value.trim(),
background_refresh_enabled: document.querySelector("#systemBackgroundRefresh").checked, background_refresh_enabled: document.querySelector("#systemBackgroundRefresh").checked,
}); });
document.querySelector("#systemTokenInput").value = ""; showToast("行情刷新设置已保存");
document.querySelector("#systemIfindTokenInput").value = "";
showToast("行情配置已保存");
await openAdminSettings(true); await openAdminSettings(true);
} catch (error) { } catch (error) {
showToast(error.message || "系统配置保存失败"); showToast(error.message || "系统配置保存失败");
+3 -3
View File
@@ -10,7 +10,7 @@ from database import ReviewDatabase
class BootstrapContainerTests(unittest.TestCase): class BootstrapContainerTests(unittest.TestCase):
def test_environment_credentials_preserve_legacy_model_fallbacks(self) -> None: def test_environment_credentials_exclude_provider_secrets_and_preserve_llm_fallbacks(self) -> None:
result = environment_credentials( result = environment_credentials(
{ {
"TUSHARE_TOKEN": " tushare ", "TUSHARE_TOKEN": " tushare ",
@@ -20,8 +20,8 @@ class BootstrapContainerTests(unittest.TestCase):
"LLM_MODEL": "legacy-model", "LLM_MODEL": "legacy-model",
} }
) )
self.assertEqual(result["tushare_token"], "tushare") self.assertNotIn("tushare_token", result)
self.assertEqual(result["ifind_refresh_token"], "refresh") self.assertNotIn("ifind_refresh_token", result)
self.assertEqual(result["platform_llm_primary_api_key"], "legacy-key") self.assertEqual(result["platform_llm_primary_api_key"], "legacy-key")
self.assertEqual(result["platform_llm_primary_base_url"], "https://legacy.example/v1") self.assertEqual(result["platform_llm_primary_base_url"], "https://legacy.example/v1")
self.assertEqual(result["platform_llm_primary_model"], "legacy-model") self.assertEqual(result["platform_llm_primary_model"], "legacy-model")
+10 -4
View File
@@ -12,6 +12,7 @@ from backend.data import (
QualityEvidence, QualityEvidence,
build_data_gateway, build_data_gateway,
) )
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
from backend.data.quality import market_timezone from backend.data.quality import market_timezone
@@ -36,9 +37,14 @@ class DataGatewayTests(unittest.TestCase):
policy.assert_allowed("market.level2", "unresolved", "display") policy.assert_allowed("market.level2", "unresolved", "display")
def test_gateway_uses_hub_facade_and_proxies(self) -> None: def test_gateway_uses_hub_facade_and_proxies(self) -> None:
settings = DatahubSettings(
base_url="http://127.0.0.1:8766",
token="hub-token",
datasets={name: DatasetFlags(name, read=True) for name in DATASETS},
)
gateway = build_data_gateway( gateway = build_data_gateway(
{"ifind_refresh_token": "refresh", "ifind_access_token": "access"}, {},
lambda: "must-not-be-used", datahub_settings=settings,
) )
client = gateway.tushare() client = gateway.tushare()
self.assertEqual(client.token, "datahub") self.assertEqual(client.token, "datahub")
@@ -63,7 +69,7 @@ class DataGatewayTests(unittest.TestCase):
/ "market" / "market"
/ "service.py" / "service.py"
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
self.assertEqual(source.count("TushareClient(self.token)"), 1) self.assertNotIn("TushareClient(self.token)", source)
self.assertIn("return gateway.tushare()", source) self.assertIn("return gateway.tushare()", source)
def test_provider_construction_has_unique_declared_owners(self) -> None: def test_provider_construction_has_unique_declared_owners(self) -> None:
@@ -71,7 +77,7 @@ class DataGatewayTests(unittest.TestCase):
owners = { owners = {
"IfindProvider": {"backend/data/gateway.py"}, "IfindProvider": {"backend/data/gateway.py"},
"MarketChartClient": {"backend/data/gateway.py"}, "MarketChartClient": {"backend/data/gateway.py"},
"TushareClient": {"backend/features/market/service.py"}, "TushareClient": set(),
"DatahubClient": {"backend/data/gateway.py"}, "DatahubClient": {"backend/data/gateway.py"},
"DatahubAwareTushareClient": {"backend/data/gateway.py"}, "DatahubAwareTushareClient": {"backend/data/gateway.py"},
"DatahubBridge": {"backend/data/gateway.py"}, "DatahubBridge": {"backend/data/gateway.py"},
+72 -17
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import ast import ast
import json import json
import re import re
import threading
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@@ -15,6 +16,7 @@ from backend.data.datahub.realtime_proxy import HubRealtimeProxy
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
from backend.data.providers.tushare_transport import TushareError from backend.data.providers.tushare_transport import TushareError
from backend.features.market.charts import MarketChartClient from backend.features.market.charts import MarketChartClient
from backend.features.system.service import SystemServiceMixin
from tests.test_datahub_bridge import FakeClient, FakeLegacy, flags from tests.test_datahub_bridge import FakeClient, FakeLegacy, flags
@@ -220,6 +222,63 @@ def blocked_urlopen(request, timeout=None):
class HubExclusiveWebsiteTests(unittest.TestCase): class HubExclusiveWebsiteTests(unittest.TestCase):
def test_website_availability_depends_on_hub_not_provider_credentials(self) -> None:
service = SystemServiceMixin()
service._system_credentials = {}
service.data_gateway = build_data_gateway({}, datahub_settings=_enabled_settings())
self.assertTrue(service.configured)
self.assertEqual(service.token, "datahub")
def test_website_vault_scrubs_historical_provider_credentials(self) -> None:
class Vault:
@staticmethod
def decrypt_json(payload):
if isinstance(payload, str):
return json.loads(payload)
return dict(payload)
@staticmethod
def encrypt_json(payload):
return dict(payload)
class Database:
system = {
"tushare_token": "old-tushare",
"ifind_refresh_token": "old-refresh",
"ifind_access_token": "old-access",
}
personal = {"tushare_token": "old-personal", "llm_primary_model": "model"}
def get_system_setting(self, _name):
return dict(self.system)
@staticmethod
def first_user_id():
return 1
def get_user_credentials(self, _user_id):
return dict(self.personal)
@staticmethod
def list_user_credentials():
return [{"user_id": 1, "encrypted_payload": '{"tushare_token":"old-personal"}'}]
def save_system_setting(self, _name, payload):
self.system = dict(payload)
def save_user_credentials(self, _user_id, payload):
self.personal = dict(payload)
service = SystemServiceMixin()
service.database = Database()
service.vault = Vault()
service.system_lock = threading.Lock()
loaded = service._load_system_credentials({})
for key in ("tushare_token", "ifind_refresh_token", "ifind_access_token"):
self.assertNotIn(key, loaded)
self.assertNotIn(key, service.database.system)
self.assertNotIn(key, service.database.personal)
def test_query_never_calls_website_tushare_transport(self) -> None: def test_query_never_calls_website_tushare_transport(self) -> None:
client = FakeClient() client = FakeClient()
legacy = FakeLegacy(TushareError("website tushare must stay dark")) legacy = FakeLegacy(TushareError("website tushare must stay dark"))
@@ -340,7 +399,6 @@ class HubExclusiveWebsiteTests(unittest.TestCase):
market = gateway.tushare() market = gateway.tushare()
market_quotes = market.try_quotes(["600000.SH"]) market_quotes = market.try_quotes(["600000.SH"])
auction = market.query("stk_auction", {"trade_date": "20240902"}, "") auction = market.query("stk_auction", {"trade_date": "20240902"}, "")
gateway.ifind.set_credentials("refresh-token", "access-token")
self.assertEqual(rows[0]["涨停原因"], "重组") self.assertEqual(rows[0]["涨停原因"], "重组")
self.assertEqual(len(quotes), 3) self.assertEqual(len(quotes), 3)
self.assertEqual(chart[-1]["close"], 10.2) self.assertEqual(chart[-1]["close"], 10.2)
@@ -349,23 +407,20 @@ class HubExclusiveWebsiteTests(unittest.TestCase):
self.assertIsNone(market.realtime_aggregator) self.assertIsNone(market.realtime_aggregator)
self.assertEqual(market.token, "datahub") self.assertEqual(market.token, "datahub")
def test_set_credentials_posts_to_hub_not_ifind(self) -> None: def test_website_does_not_accept_or_forward_provider_credentials(self) -> None:
seen: list[str] = [] proxy = HubIfindProxy(DatahubBridge(_enabled_settings(), FakeClient()))
self.assertFalse(hasattr(proxy, "set_credentials"))
client_source = (ROOT / "backend" / "data" / "datahub" / "client.py").read_text(encoding="utf-8")
application_source = (ROOT / "backend" / "application.py").read_text(encoding="utf-8")
self.assertNotIn("/v1/credentials", client_source)
self.assertNotIn("ifind_refresh_token", application_source)
def urlopen(request, timeout=None): def test_site_configuration_depends_on_hub_not_provider_credentials(self) -> None:
url = str(getattr(request, "full_url", None) or request) service = SystemServiceMixin()
seen.append(url) service._system_credentials = {}
if any(host in url for host in BLOCKED_HOSTS): service.data_gateway = build_data_gateway({}, datahub_settings=_enabled_settings())
raise AssertionError(f"website opened blocked host: {url}") self.assertTrue(service.configured)
return _Resp(hub_payload(request)) self.assertEqual(service.token, "datahub")
settings = _enabled_settings()
hub_client = DatahubClient(settings, urlopen=urlopen)
proxy = HubIfindProxy(DatahubBridge(settings, hub_client))
proxy.set_credentials("refresh-token", "access-token")
self.assertTrue(any("/v1/credentials/ifind" in url for url in seen))
self.assertFalse(any("51ifind.com" in url for url in seen))
self.assertFalse(any("quantapi" in url for url in seen))
def test_compose_passes_ifind_env_to_hub(self) -> None: def test_compose_passes_ifind_env_to_hub(self) -> None:
overlay = (ROOT / "compose.datahub.yaml").read_text(encoding="utf-8") overlay = (ROOT / "compose.datahub.yaml").read_text(encoding="utf-8")
+1 -1
View File
@@ -58,7 +58,7 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
"data-system-edit-model", "data-system-edit-model",
"data-system-open-member", "data-system-open-member",
"管理员专区", "管理员专区",
"保存密钥", "刷新状态",
"保存分工", "保存分工",
'location.assign("/login/")', 'location.assign("/login/")',
): ):
+32 -33
View File
@@ -53,6 +53,18 @@ class RealtimeClientStub:
} }
class DataGatewayStub:
def __init__(self, client):
self.client = client
def tushare(self):
return self.client
@staticmethod
def datahub_status():
return {"configured": True}
class FixedMarketDatetime(datetime): class FixedMarketDatetime(datetime):
fixed_now = datetime(2026, 7, 31, 10, 30).astimezone() fixed_now = datetime(2026, 7, 31, 10, 30).astimezone()
@@ -195,6 +207,8 @@ class StockDetailRealtimeTests(unittest.TestCase):
self.service.ifind = None self.service.ifind = None
self.service.realtime_aggregator = None self.service.realtime_aggregator = None
self.service.chart_data = None self.service.chart_data = None
self.service._market_client_override = RealtimeClientStub("datahub")
self.service.data_gateway = DataGatewayStub(self.service._market_client_override)
RealtimeClientStub.quote_calls = 0 RealtimeClientStub.quote_calls = 0
DeniedRealtimeClientStub.quote_calls = 0 DeniedRealtimeClientStub.quote_calls = 0
@@ -218,9 +232,7 @@ class StockDetailRealtimeTests(unittest.TestCase):
"moneyflow": {}, "moneyflow": {},
} }
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch( with patch("backend.features.market.service.datetime", FixedMarketDatetime):
"backend.features.market.service.TushareClient", RealtimeClientStub
):
result = self.service._prepare_stock_detail(cached, "002141", today) result = self.service._prepare_stock_detail(cached, "002141", today)
self.assertEqual(result["meta"]["trade_date"], FixedMarketDatetime.fixed_now.strftime("%Y-%m-%d")) self.assertEqual(result["meta"]["trade_date"], FixedMarketDatetime.fixed_now.strftime("%Y-%m-%d"))
@@ -240,9 +252,7 @@ class StockDetailRealtimeTests(unittest.TestCase):
"stock": {"code": "002141", "price": 10, "change": 1.2}, "stock": {"code": "002141", "price": 10, "change": 1.2},
"prices": [{"trade_date": historical, "close": 10, "change": 1.2}], "prices": [{"trade_date": historical, "close": 10, "change": 1.2}],
} }
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch( with patch("backend.features.market.service.datetime", FixedMarketDatetime):
"backend.features.market.service.TushareClient", RealtimeClientStub
):
result = self.service._prepare_stock_detail(payload, "002141", historical) result = self.service._prepare_stock_detail(payload, "002141", historical)
self.assertEqual(result["stock"]["change"], 1.2) self.assertEqual(result["stock"]["change"], 1.2)
@@ -279,9 +289,7 @@ class StockDetailRealtimeTests(unittest.TestCase):
}, },
], ],
} }
with patch("backend.features.market.service.datetime", FixedPreopenDatetime), patch( with patch("backend.features.market.service.datetime", FixedPreopenDatetime):
"backend.features.market.service.TushareClient", RealtimeClientStub
):
result = self.service._prepare_stock_detail(payload, "002141", today) result = self.service._prepare_stock_detail(payload, "002141", today)
self.assertEqual(result["meta"]["trade_date"], yesterday) self.assertEqual(result["meta"]["trade_date"], yesterday)
@@ -294,11 +302,10 @@ class StockDetailRealtimeTests(unittest.TestCase):
today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d") today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d")
aggregator = FreeQuoteAggregator(_free_quote()) aggregator = FreeQuoteAggregator(_free_quote())
self.service.realtime_aggregator = aggregator self.service.realtime_aggregator = aggregator
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
DeniedRealtimeClientStub.quote_calls = 0 DeniedRealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch( with patch("backend.features.market.service.datetime", FixedMarketDatetime):
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
):
result = self.service._prepare_stock_detail(_history_payload(), "002141", today) result = self.service._prepare_stock_detail(_history_payload(), "002141", today)
bar = result["prices"][-1] bar = result["prices"][-1]
@@ -327,11 +334,10 @@ class StockDetailRealtimeTests(unittest.TestCase):
), ),
) )
self.service.realtime_aggregator = aggregator self.service.realtime_aggregator = aggregator
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
DeniedRealtimeClientStub.quote_calls = 0 DeniedRealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch( with patch("backend.features.market.service.datetime", FixedMarketDatetime):
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
):
result = self.service._prepare_stock_detail(_history_payload("600000"), "600000", today) result = self.service._prepare_stock_detail(_history_payload("600000"), "600000", today)
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-31") self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-31")
@@ -365,9 +371,7 @@ class StockDetailRealtimeTests(unittest.TestCase):
}, },
] ]
) )
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch( with patch("backend.features.market.service.datetime", FixedMarketDatetime):
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
):
result = self.service._prepare_stock_detail(_history_payload("300750"), "300750", today) result = self.service._prepare_stock_detail(_history_payload("300750"), "300750", today)
bar = result["prices"][-1] bar = result["prices"][-1]
@@ -384,11 +388,10 @@ class StockDetailRealtimeTests(unittest.TestCase):
today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d") today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d")
self.service.realtime_aggregator = FreeQuoteAggregator(fail=True) self.service.realtime_aggregator = FreeQuoteAggregator(fail=True)
self.service.chart_data = IntradayChartStub([], trade_date="2026-07-30") self.service.chart_data = IntradayChartStub([], trade_date="2026-07-30")
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
DeniedRealtimeClientStub.quote_calls = 0 DeniedRealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch( with patch("backend.features.market.service.datetime", FixedMarketDatetime):
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
):
result = self.service._prepare_stock_detail(_history_payload(), "002141", today) result = self.service._prepare_stock_detail(_history_payload(), "002141", today)
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-30") self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-30")
@@ -401,10 +404,9 @@ class StockDetailRealtimeTests(unittest.TestCase):
self.service.realtime_aggregator = FreeQuoteAggregator( self.service.realtime_aggregator = FreeQuoteAggregator(
_free_quote(quote_time_epoch=int(datetime(2026, 7, 31, 11, 30).timestamp())) _free_quote(quote_time_epoch=int(datetime(2026, 7, 31, 11, 30).timestamp()))
) )
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
DeniedRealtimeClientStub.quote_calls = 0 DeniedRealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedLunchDatetime), patch( with patch("backend.features.market.service.datetime", FixedLunchDatetime):
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
):
result = self.service._prepare_stock_detail(_history_payload(), "002141", today) result = self.service._prepare_stock_detail(_history_payload(), "002141", today)
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-31") self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-31")
@@ -413,10 +415,9 @@ class StockDetailRealtimeTests(unittest.TestCase):
def test_after_close_keeps_forming_bar_until_official_ready(self): def test_after_close_keeps_forming_bar_until_official_ready(self):
today = FixedAfterCloseDatetime.fixed_now.strftime("%Y%m%d") today = FixedAfterCloseDatetime.fixed_now.strftime("%Y%m%d")
self.service.realtime_aggregator = FreeQuoteAggregator(_free_quote()) self.service.realtime_aggregator = FreeQuoteAggregator(_free_quote())
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
DeniedRealtimeClientStub.quote_calls = 0 DeniedRealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime), patch( with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime):
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
):
forming = self.service._prepare_stock_detail(_history_payload(), "002141", today) forming = self.service._prepare_stock_detail(_history_payload(), "002141", today)
self.assertEqual(forming["prices"][-1]["trade_date"], "2026-07-31") self.assertEqual(forming["prices"][-1]["trade_date"], "2026-07-31")
self.assertTrue(forming["prices"][-1]["realtime"]) self.assertTrue(forming["prices"][-1]["realtime"])
@@ -435,9 +436,8 @@ class StockDetailRealtimeTests(unittest.TestCase):
} }
) )
RealtimeClientStub.quote_calls = 0 RealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime), patch( self.service._market_client_override = RealtimeClientStub("datahub")
"backend.features.market.service.TushareClient", RealtimeClientStub with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime):
):
replaced = self.service._prepare_stock_detail(official, "002141", today) replaced = self.service._prepare_stock_detail(official, "002141", today)
self.assertEqual(replaced["prices"][-1]["close"], 10.7) self.assertEqual(replaced["prices"][-1]["close"], 10.7)
@@ -462,10 +462,9 @@ class StockDetailRealtimeTests(unittest.TestCase):
} }
) )
self.service.realtime_aggregator = FreeQuoteAggregator(_free_quote()) self.service.realtime_aggregator = FreeQuoteAggregator(_free_quote())
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
DeniedRealtimeClientStub.quote_calls = 0 DeniedRealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch( with patch("backend.features.market.service.datetime", FixedMarketDatetime):
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
):
result = self.service._prepare_stock_detail(payload, "002141", today) result = self.service._prepare_stock_detail(payload, "002141", today)
self.assertEqual(len(result["prices"]), 2) self.assertEqual(len(result["prices"]), 2)