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