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.
|
# 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
@@ -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
@@ -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. 服务器要求
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 || "系统配置保存失败");
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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/")',
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user