rebuild(stage-5): establish market data gateway and charts

This commit is contained in:
leefer
2026-07-30 02:35:42 +08:00
parent 40ad5d6836
commit cf0ab7026f
45 changed files with 2701 additions and 46 deletions
+22 -1
View File
@@ -3,6 +3,10 @@ from __future__ import annotations
from dataclasses import dataclass
from backend.bootstrap.settings import Settings
from backend.data.gateway import DataGateway
from backend.data.policy import DataSourcePolicy
from backend.data.providers import EastmoneyProvider, IfindProvider, TushareProvider
from backend.data.repository import MarketRepository
from backend.database.connection import Database
from backend.database.repositories.status import DatabaseStatusRepository
from backend.features.accounts.credentials import SystemCredentialService
@@ -12,6 +16,7 @@ from backend.features.accounts.service import (
AccountService,
MembershipService,
)
from backend.features.market import MarketService
from backend.security import PasswordHasher, load_or_create_cipher
@@ -24,6 +29,7 @@ class ApplicationContainer:
memberships: MembershipService
system_credentials: SystemCredentialService
model_pool: ModelPoolService
market: MarketService
def build_container(settings: Settings) -> ApplicationContainer:
@@ -32,12 +38,27 @@ def build_container(settings: Settings) -> ApplicationContainer:
credential_repository = SystemCredentialRepository()
model_pool_repository = ModelPoolRepository()
cipher = load_or_create_cipher(settings)
credentials = SystemCredentialService(database, credential_repository, cipher)
gateway = DataGateway(
database,
MarketRepository(),
(
TushareProvider(lambda: credentials.get("tushare_token")),
IfindProvider(
lambda: credentials.get("ifind_refresh_token"),
lambda: credentials.get("ifind_access_token"),
),
EastmoneyProvider(),
),
DataSourcePolicy(),
)
return ApplicationContainer(
settings=settings,
database=database,
database_status=DatabaseStatusRepository(database),
accounts=AccountService(database, account_repository, PasswordHasher(), cipher),
memberships=MembershipService(database, account_repository),
system_credentials=SystemCredentialService(database, credential_repository, cipher),
system_credentials=credentials,
model_pool=ModelPoolService(database, model_pool_repository, cipher),
market=MarketService(gateway),
)
+3
View File
@@ -0,0 +1,3 @@
from backend.data.gateway import DataGateway
__all__ = ["DataGateway"]
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from typing import Any
class DataSource(StrEnum):
TUSHARE = "tushare"
IFIND = "ifind"
EASTMONEY = "eastmoney"
TENCENT = "tencent"
LOCAL = "local"
class DataUsage(StrEnum):
DISPLAY = "display"
CALCULATION = "calculation"
class SnapshotState(StrEnum):
REALTIME = "realtime"
FINAL = "final"
ARCHIVE = "archive"
@dataclass(frozen=True, slots=True)
class ObservationMetadata:
source: DataSource
observed_at: datetime
unit: str
adjustment: str
freshness_seconds: int
coverage: float
state: SnapshotState
usage: DataUsage
def __post_init__(self) -> None:
if not 0 <= self.coverage <= 1:
raise ValueError("coverage must be between zero and one")
if self.freshness_seconds < 0:
raise ValueError("freshness_seconds cannot be negative")
@dataclass(frozen=True, slots=True)
class ProviderResult:
rows: tuple[dict[str, Any], ...]
metadata: ObservationMetadata
@dataclass(frozen=True, slots=True)
class TradeContext:
requested_date: str
actual_date: str | None
previous_date: str | None
observed_at: datetime | None
state: SnapshotState | None
carried_forward: bool
message: str
@dataclass(frozen=True, slots=True)
class MarketEntity:
entity_type: str
identifier: str
code: str
name: str
sector: str | None = None
@dataclass(frozen=True, slots=True)
class ChartPoint:
time: str
open: float
high: float
low: float
close: float
volume: float
amount: float
average: float | None = None
@dataclass(frozen=True, slots=True)
class ChartSeries:
entity: MarketEntity
interval: str
trade_date: str
previous_close: float | None
points: tuple[ChartPoint, ...]
metadata: ObservationMetadata
+379
View File
@@ -0,0 +1,379 @@
from __future__ import annotations
import json
from datetime import date, datetime, time, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from backend.data.contracts import (
ChartPoint,
ChartSeries,
DataSource,
DataUsage,
MarketEntity,
ObservationMetadata,
SnapshotState,
TradeContext,
)
from backend.data.policy import DataSourcePolicy
from backend.data.providers.base import MarketDataProvider, ProviderError
from backend.data.quality import DataQualityError, require_quality
from backend.data.repository import MarketRepository
from backend.database.connection import Database
SHANGHAI = ZoneInfo("Asia/Shanghai")
class MarketDataUnavailable(RuntimeError):
pass
class DataGateway:
def __init__(
self,
database: Database,
repository: MarketRepository,
providers: tuple[MarketDataProvider, ...],
policy: DataSourcePolicy,
) -> None:
self._database = database
self._repository = repository
self._providers = {provider.source: provider for provider in providers}
self._policy = policy
def refresh_reference(self, now: datetime | None = None) -> dict[str, int | str]:
clock = now or datetime.now(SHANGHAI)
provider = self._provider(DataSource.TUSHARE)
start = (clock.date() - timedelta(days=370)).isoformat()
end = (clock.date() + timedelta(days=40)).isoformat()
calendar = require_quality("calendar", provider.calendar(start, end))
entities = require_quality("entities", provider.entities())
observed_at = clock.isoformat(timespec="seconds")
with self._database.transaction() as connection:
self._repository.replace_calendar(
connection, calendar.rows, calendar.metadata.source.value, observed_at
)
self._repository.replace_stocks(
connection, entities.rows, entities.metadata.source.value, observed_at
)
return {
"calendar_days": len(calendar.rows),
"entities": len(entities.rows),
"observed_at": observed_at,
}
def trade_context(
self, requested_date: str | None = None, now: datetime | None = None
) -> TradeContext:
clock = now or datetime.now(SHANGHAI)
requested = _date(requested_date or clock.date().isoformat())
with self._database.read() as connection:
summary = self._repository.latest_summary(connection, requested)
if summary is None:
return TradeContext(
requested_date=requested,
actual_date=None,
previous_date=None,
observed_at=None,
state=None,
carried_forward=False,
message="等待管理员首次同步真实行情",
)
actual = str(summary["trade_date"])
observed_at = datetime.fromisoformat(str(summary["observed_at"]))
state = SnapshotState(str(summary["state"]))
carried = actual != requested
with self._database.read() as connection:
dates = self._repository.open_dates(connection, actual, 2)
return TradeContext(
requested_date=requested,
actual_date=actual,
previous_date=dates[1] if len(dates) > 1 else None,
observed_at=observed_at,
state=state,
carried_forward=carried,
message="沿用最近真实收盘快照" if carried else "",
)
def latest_available_date(self, now: datetime | None = None) -> str:
context = self.trade_context(now=now)
return context.actual_date or (now or datetime.now(SHANGHAI)).date().isoformat()
def summary(self, requested_date: str | None = None) -> dict[str, Any]:
context = self.trade_context(requested_date)
if context.actual_date is None:
return {"context": context, "values": None}
with self._database.read() as connection:
row = self._repository.latest_summary(connection, context.actual_date)
return {"context": context, "values": json.loads(str(row["payload_json"])) if row else None}
def search(self, query: str) -> tuple[MarketEntity, ...]:
with self._database.read() as connection:
return self._repository.search(connection, query)
def chart(
self,
entity_type: str,
identifier: str,
interval: str,
now: datetime | None = None,
) -> ChartSeries:
if entity_type not in {"stock", "sector", "theme", "index"}:
raise MarketDataUnavailable("不支持的行情标的类型")
if interval not in {"day", "minute"}:
raise MarketDataUnavailable("不支持的行情周期")
entity = self._resolve_entity(entity_type, identifier)
clock = now or datetime.now(SHANGHAI)
with self._database.read() as connection:
cached = self._repository.chart(connection, entity_type, entity.identifier, interval)
if cached and not _chart_stale(cached, interval, clock):
return _stored_chart(entity, cached)
try:
series = self._load_chart(entity, interval, clock)
except (ProviderError, DataQualityError) as exc:
if cached:
return _stored_chart(entity, cached)
raise MarketDataUnavailable("当前没有可用的真实行情数据") from exc
self._save_chart(series)
return series
def _load_chart(self, entity: MarketEntity, interval: str, clock: datetime) -> ChartSeries:
dataset = "daily_chart" if interval == "day" else "minute_chart"
target_dates = self._chart_dates(clock)
errors: list[Exception] = []
for source in self._policy.candidates(dataset, DataUsage.DISPLAY):
provider = self._providers.get(source)
if provider is None or not provider.configured:
continue
self._policy.assert_allowed(source, DataUsage.DISPLAY)
for target in target_dates:
try:
raw = (
provider.daily(entity.entity_type, entity.identifier, target)
if interval == "day"
else provider.minute(entity.entity_type, entity.identifier, target)
)
require_quality(dataset, raw)
normalized = _normalize_chart(entity, interval, raw.rows, raw.metadata, clock)
if normalized.points:
return normalized
except (ProviderError, DataQualityError, ValueError) as exc:
errors.append(exc)
if interval == "day":
break
raise MarketDataUnavailable("当前没有可用的真实行情数据") from (
errors[-1] if errors else None
)
def _chart_dates(self, clock: datetime) -> tuple[str, ...]:
today = clock.date().isoformat()
with self._database.read() as connection:
dates = self._repository.open_dates(connection, today, 8)
if dates:
return dates
return tuple((clock.date() - timedelta(days=offset)).isoformat() for offset in range(8))
def _resolve_entity(self, entity_type: str, identifier: str) -> MarketEntity:
normalized = identifier.strip().upper()
with self._database.read() as connection:
entity = self._repository.entity(connection, entity_type, normalized)
if entity is None and entity_type == "stock" and normalized.isdigit():
matches = self._repository.search(connection, normalized, 4)
entity = next(
(
item
for item in matches
if item.entity_type == "stock" and item.code == normalized
),
None,
)
if entity:
return entity
if entity_type == "stock" and len(normalized) == 6 and normalized.isdigit():
suffix = (
"BJ"
if normalized.startswith(("4", "8", "9"))
else "SH"
if normalized.startswith("6")
else "SZ"
)
return MarketEntity("stock", f"{normalized}.{suffix}", normalized, normalized)
raise MarketDataUnavailable("未找到该行情标的")
def _save_chart(self, series: ChartSeries) -> None:
payload = {
"previous_close": series.previous_close,
"points": [
{
"time": point.time,
"open": point.open,
"high": point.high,
"low": point.low,
"close": point.close,
"volume": point.volume,
"amount": point.amount,
"average": point.average,
}
for point in series.points
],
}
with self._database.transaction() as connection:
self._repository.save_chart(
connection,
entity_type=series.entity.entity_type,
identifier=series.entity.identifier,
interval=series.interval,
trade_date=series.trade_date,
observed_at=series.metadata.observed_at.isoformat(timespec="seconds"),
source=series.metadata.source.value,
usage=series.metadata.usage.value,
adjustment=series.metadata.adjustment,
coverage=series.metadata.coverage,
payload=payload,
)
def _provider(self, source: DataSource) -> MarketDataProvider:
provider = self._providers.get(source)
if provider is None or not provider.configured:
raise MarketDataUnavailable("所需行情服务尚未配置")
return provider
def _normalize_chart(
entity: MarketEntity,
interval: str,
rows: tuple[dict[str, Any], ...],
metadata: ObservationMetadata,
clock: datetime,
) -> ChartSeries:
parsed: list[tuple[str, ChartPoint, float | None]] = []
for row in rows:
stamp = str(row.get("trade_date") or row.get("time") or row.get("trade_time") or "")
trade_date = _row_date(stamp)
point_time = trade_date if interval == "day" else _row_time(stamp)
close = _number(row.get("close"))
open_price = _number(row.get("open"))
high = _number(row.get("high"))
low = _number(row.get("low"))
if not trade_date or close <= 0 or open_price <= 0 or high <= 0 or low <= 0:
continue
if interval == "minute" and not "09:30" <= point_time <= "15:00":
continue
volume = _number(row.get("volume", row.get("vol")))
amount = _number(row.get("amount"))
if metadata.source is DataSource.TUSHARE:
volume *= 100
amount *= 1000
parsed.append(
(
trade_date,
ChartPoint(
time=point_time,
open=open_price,
high=high,
low=low,
close=close,
volume=volume,
amount=amount,
average=_optional_number(row.get("avgPrice", row.get("average"))),
),
_optional_number(row.get("preClose", row.get("pre_close"))),
)
)
parsed.sort(key=lambda item: (item[0], item[1].time))
if not parsed:
raise DataQualityError("chart contains no valid points")
if interval == "minute":
latest = parsed[-1][0]
parsed = [item for item in parsed if item[0] == latest]
else:
today = clock.date().isoformat()
if parsed[-1][0] == today and not _valid_today_bar(parsed[-1][1], clock):
parsed.pop()
if not parsed:
raise DataQualityError("chart contains no completed bar")
parsed = parsed[-90:]
previous = parsed[0][2]
if previous is None and interval == "day" and len(parsed) > 1:
previous = parsed[-2][1].close
return ChartSeries(
entity=entity,
interval=interval,
trade_date=parsed[-1][0],
previous_close=previous,
points=tuple(item[1] for item in parsed),
metadata=metadata,
)
def _valid_today_bar(point: ChartPoint, clock: datetime) -> bool:
if clock.time() < time(9, 30):
return False
return (
point.volume > 0
and point.amount > 0
and point.high >= max(point.open, point.close)
and point.low <= min(point.open, point.close)
)
def _chart_stale(row: Any, interval: str, clock: datetime) -> bool:
observed = datetime.fromisoformat(str(row["observed_at"]))
if interval == "day":
return row["trade_date"] < clock.date().isoformat() and clock.time() >= time(15, 5)
return (clock - observed.astimezone(SHANGHAI)).total_seconds() > 30
def _stored_chart(entity: MarketEntity, row: Any) -> ChartSeries:
payload = json.loads(str(row["payload_json"]))
points = tuple(ChartPoint(**point) for point in payload.get("points") or [])
metadata = ObservationMetadata(
source=DataSource(str(row["source"])),
observed_at=datetime.fromisoformat(str(row["observed_at"])),
unit="yuan/share",
adjustment=str(row["adjustment"]),
freshness_seconds=0,
coverage=float(row["coverage"]),
state=SnapshotState.ARCHIVE,
usage=DataUsage(str(row["usage"])),
)
return ChartSeries(
entity,
str(row["interval"]),
str(row["trade_date"]),
payload.get("previous_close"),
points,
metadata,
)
def _date(value: str) -> str:
try:
return date.fromisoformat(value).isoformat()
except ValueError as exc:
raise MarketDataUnavailable("日期格式无效") from exc
def _row_date(value: str) -> str:
compact = value[:10].replace("-", "")
if len(compact) != 8 or not compact.isdigit():
return ""
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
def _row_time(value: str) -> str:
if " " in value:
return value.split(" ", 1)[1][:5]
return value[-8:-3] if len(value) >= 8 else value[:5]
def _number(value: Any) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
def _optional_number(value: Any) -> float | None:
number = _number(value)
return number if number > 0 else None
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from dataclasses import dataclass
from backend.data.contracts import DataSource, DataUsage
class DataPolicyError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class DataSourcePolicy:
calculation_sources: frozenset[DataSource] = frozenset(
{DataSource.TUSHARE, DataSource.IFIND, DataSource.LOCAL}
)
display_sources: frozenset[DataSource] = frozenset(DataSource)
def assert_allowed(self, source: DataSource, usage: DataUsage) -> None:
allowed = (
self.calculation_sources if usage is DataUsage.CALCULATION else self.display_sources
)
if source not in allowed:
raise DataPolicyError(f"{source.value} cannot be used for {usage.value}")
def candidates(self, dataset: str, usage: DataUsage) -> tuple[DataSource, ...]:
routes = {
("calendar", DataUsage.CALCULATION): (DataSource.TUSHARE,),
("entities", DataUsage.CALCULATION): (DataSource.TUSHARE,),
("daily_chart", DataUsage.DISPLAY): (DataSource.IFIND, DataSource.TUSHARE),
("minute_chart", DataUsage.DISPLAY): (DataSource.IFIND, DataSource.EASTMONEY),
("realtime_quote", DataUsage.CALCULATION): (DataSource.IFIND, DataSource.TUSHARE),
}
return routes.get((dataset, usage), ())
+5
View File
@@ -0,0 +1,5 @@
from backend.data.providers.eastmoney import EastmoneyProvider
from backend.data.providers.ifind import IfindProvider
from backend.data.providers.tushare import TushareProvider
__all__ = ["EastmoneyProvider", "IfindProvider", "TushareProvider"]
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
from typing import Protocol
from backend.data.contracts import DataSource, ProviderResult
class ProviderError(RuntimeError):
pass
class MarketDataProvider(Protocol):
source: DataSource
@property
def configured(self) -> bool: ...
def calendar(self, start_date: str, end_date: str) -> ProviderResult: ...
def entities(self) -> ProviderResult: ...
def daily(self, entity_type: str, identifier: str, end_date: str) -> ProviderResult: ...
def minute(self, entity_type: str, identifier: str, trade_date: str) -> ProviderResult: ...
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
import json
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from zoneinfo import ZoneInfo
from backend.data.contracts import (
DataSource,
DataUsage,
ObservationMetadata,
ProviderResult,
SnapshotState,
)
from backend.data.providers.base import ProviderError
SHANGHAI = ZoneInfo("Asia/Shanghai")
INDEX_CODES = {"000001.SH": "1.000001", "399001.SZ": "0.399001", "399006.SZ": "0.399006"}
class EastmoneyProvider:
source = DataSource.EASTMONEY
url = "https://push2delay.eastmoney.com/api/qt/stock/trends2/get"
def __init__(self, timeout: int = 6) -> None:
self._timeout = timeout
@property
def configured(self) -> bool:
return True
def calendar(self, start_date: str, end_date: str) -> ProviderResult:
raise ProviderError("The display provider is not a calendar authority")
def entities(self) -> ProviderResult:
raise ProviderError("The display provider is not an entity authority")
def daily(self, entity_type: str, identifier: str, end_date: str) -> ProviderResult:
raise ProviderError("The display provider does not supply canonical daily bars")
def minute(self, entity_type: str, identifier: str, trade_date: str) -> ProviderResult:
secid = self._secid(entity_type, identifier)
params = urllib.parse.urlencode(
{
"secid": secid,
"fields1": "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11,f12,f13",
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58",
"iscr": "0",
"ndays": "1",
}
)
request = urllib.request.Request(
f"{self.url}?{params}",
headers={"Accept": "application/json", "User-Agent": "XiaobaiReview/2"},
)
try:
with urllib.request.urlopen(request, timeout=self._timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
raise ProviderError("展示行情请求失败") from exc
data = payload.get("data") or {}
rows = []
for raw in data.get("trends") or []:
fields = str(raw).split(",")
if len(fields) < 8 or " " not in fields[0]:
continue
date, time = fields[0].split(" ", 1)
if date != trade_date or not "09:30" <= time[:5] <= "15:00":
continue
rows.append(
{
"time": fields[0],
"open": fields[1],
"close": fields[2],
"high": fields[3],
"low": fields[4],
"volume": fields[5],
"amount": fields[6],
"avgPrice": fields[7],
"preClose": data.get("preClose"),
}
)
metadata = ObservationMetadata(
source=self.source,
observed_at=datetime.now(SHANGHAI),
unit="yuan/share",
adjustment="unadjusted",
freshness_seconds=0,
coverage=1 if rows else 0,
state=SnapshotState.REALTIME,
usage=DataUsage.DISPLAY,
)
return ProviderResult(tuple(rows), metadata)
@staticmethod
def _secid(entity_type: str, identifier: str) -> str:
if entity_type == "index" and identifier in INDEX_CODES:
return INDEX_CODES[identifier]
code = identifier.split(".")[0]
if entity_type == "stock" and len(code) == 6 and code.isdigit():
market = "1" if code.startswith(("5", "6", "9")) else "0"
return f"{market}.{code}"
raise ProviderError("该标的暂无展示分时数据")
+233
View File
@@ -0,0 +1,233 @@
from __future__ import annotations
import json
import threading
import urllib.error
import urllib.request
from collections.abc import Callable
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo
from backend.data.contracts import (
DataSource,
DataUsage,
ObservationMetadata,
ProviderResult,
SnapshotState,
)
from backend.data.providers.base import ProviderError
SHANGHAI = ZoneInfo("Asia/Shanghai")
class IfindProvider:
source = DataSource.IFIND
base_url = "https://quantapi.51ifind.com/api/v1"
auth_error_codes = {-1302, -1303, -1304, -4302, -4303}
def __init__(
self,
refresh_token: str | None | Callable[[], str | None],
access_token: str | None | Callable[[], str | None],
timeout: int = 15,
) -> None:
self._refresh_provider = refresh_token if callable(refresh_token) else lambda: refresh_token
self._access_provider = access_token if callable(access_token) else lambda: access_token
self._issued_access = ""
self._timeout = timeout
self._lock = threading.Lock()
@property
def configured(self) -> bool:
return bool(self._refresh() or self._configured_access())
def calendar(self, start_date: str, end_date: str) -> ProviderResult:
raise ProviderError("iFinD is not the calendar authority")
def entities(self) -> ProviderResult:
raise ProviderError("iFinD is not the entity-directory authority")
def daily(self, entity_type: str, identifier: str, end_date: str) -> ProviderResult:
end = datetime.strptime(_compact(end_date), "%Y%m%d")
start = end.replace(year=end.year - 1).strftime("%Y-%m-%d")
payload = self._request(
"cmd_history_quotation",
{
"codes": identifier,
"indicators": "open,high,low,close,volume,amount",
"startdate": start,
"enddate": end.strftime("%Y-%m-%d"),
"functionpara": {"CPS": "forward1", "Fill": "Omit"},
},
)
return _result(payload, "yuan/share", "forward", SnapshotState.ARCHIVE)
def minute(self, entity_type: str, identifier: str, trade_date: str) -> ProviderResult:
date = _display(trade_date)
payload = self._request(
"high_frequency",
{
"codes": identifier,
"indicators": "open,high,low,close,volume,amount,avgPrice",
"starttime": f"{date} 09:30:00",
"endtime": f"{date} 15:00:00",
"functionpara": {
"CPS": "forward1",
"Fill": "Previous",
"Timeformat": "LocalTime",
"Interval": "1",
"Limitstart": "09:30:00",
"Limitend": "15:00:00",
},
},
)
return _result(payload, "yuan/share", "forward", SnapshotState.REALTIME)
def _request(self, endpoint: str, body: dict[str, Any]) -> dict[str, Any]:
if not self.configured:
raise ProviderError("实时行情服务尚未配置")
payload = self._post(endpoint, body, self._access())
code = _error_code(payload)
if self._auth_error(payload) and self._refresh():
payload = self._post(endpoint, body, self._refresh_access())
code = _error_code(payload)
if code != 0:
raise ProviderError(
str(payload.get("errmsg") or payload.get("message") or "实时行情服务拒绝请求")
)
return payload
def _access(self) -> str:
with self._lock:
if self._issued_access:
return self._issued_access
configured = self._configured_access()
if configured:
return configured
refresh = self._refresh()
if not refresh:
raise ProviderError("实时行情服务尚未配置")
payload = self._post("get_access_token", {}, "", refresh)
token = str((payload.get("data") or {}).get("access_token") or "").strip()
if not token:
raise ProviderError("实时行情服务授权失败")
self._issued_access = token
return token
def _refresh_access(self) -> str:
refresh = self._refresh()
if not refresh:
raise ProviderError("实时行情服务授权失败")
with self._lock:
payload = self._post("get_access_token", {}, "", refresh)
token = str((payload.get("data") or {}).get("access_token") or "").strip()
if not token:
raise ProviderError("实时行情服务授权失败")
self._issued_access = token
return token
def _auth_error(self, payload: dict[str, Any]) -> bool:
message = str(payload.get("errmsg") or payload.get("message") or "").casefold()
return (
_error_code(payload) in self.auth_error_codes
or "token" in message
or "鉴权" in message
)
def _refresh(self) -> str:
return str(self._refresh_provider() or "").strip()
def _configured_access(self) -> str:
return str(self._access_provider() or "").strip()
def _post(
self, endpoint: str, body: dict[str, Any], access: str, refresh: str = ""
) -> dict[str, Any]:
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "XiaobaiReview/2",
"ifindlang": "cn",
}
if access:
headers["access_token"] = access
if refresh:
headers["refresh_token"] = refresh
request = urllib.request.Request(
f"{self.base_url}/{endpoint}",
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=self._timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
raise ProviderError("实时行情服务请求失败") from exc
if not isinstance(payload, dict):
raise ProviderError("实时行情服务返回格式无效")
return payload
def _result(
payload: dict[str, Any], unit: str, adjustment: str, state: SnapshotState
) -> ProviderResult:
tables = payload.get("tables") or (payload.get("data") or {}).get("tables") or []
if isinstance(tables, dict):
tables = [tables]
rows: list[dict[str, Any]] = []
for block in tables:
columns = block.get("table") or {}
if not columns:
continue
times = block.get("time") or []
codes = block.get("thscode") or block.get("thscodes") or []
if isinstance(codes, str):
codes = [codes]
size = max(
(len(value) for value in columns.values() if isinstance(value, list)),
default=len(times) if isinstance(times, list) else 1,
)
for index in range(size):
row = {
key: values[index]
if isinstance(values, list) and index < len(values)
else values if index == 0 else None
for key, values in columns.items()
}
if isinstance(times, list) and index < len(times):
row["time"] = times[index]
if codes:
row["thscode"] = codes[index] if index < len(codes) else codes[0]
rows.append(row)
metadata = ObservationMetadata(
source=DataSource.IFIND,
observed_at=datetime.now(SHANGHAI),
unit=unit,
adjustment=adjustment,
freshness_seconds=0,
coverage=1 if rows else 0,
state=state,
usage=DataUsage.DISPLAY,
)
return ProviderResult(tuple(rows), metadata)
def _compact(value: str) -> str:
normalized = value.replace("-", "")
if len(normalized) != 8 or not normalized.isdigit():
raise ProviderError("日期格式无效")
return normalized
def _display(value: str) -> str:
compact = _compact(value)
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
def _error_code(payload: dict[str, Any]) -> int:
try:
return int(payload.get("errorcode", payload.get("code", 0)) or 0)
except (TypeError, ValueError):
return -1
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
import json
import urllib.error
import urllib.request
from collections.abc import Callable
from dataclasses import replace
from datetime import datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from backend.data.contracts import (
DataSource,
DataUsage,
ObservationMetadata,
ProviderResult,
SnapshotState,
)
from backend.data.providers.base import ProviderError
SHANGHAI = ZoneInfo("Asia/Shanghai")
class TushareProvider:
source = DataSource.TUSHARE
url = "http://api.tushare.pro"
def __init__(self, token: str | None | Callable[[], str | None], timeout: int = 20) -> None:
self._token_provider = token if callable(token) else lambda: token
self._timeout = timeout
@property
def configured(self) -> bool:
return bool(self._token())
def calendar(self, start_date: str, end_date: str) -> ProviderResult:
result = self._query(
"trade_cal",
{"exchange": "SSE", "start_date": _compact(start_date), "end_date": _compact(end_date)},
"cal_date,is_open,pretrade_date",
unit="calendar_day",
)
days = (
datetime.fromisoformat(end_date).date() - datetime.fromisoformat(start_date).date()
).days + 1
return ProviderResult(
result.rows,
replace(result.metadata, coverage=min(len(result.rows) / max(days, 1), 1)),
)
def entities(self) -> ProviderResult:
rows: list[dict[str, Any]] = []
for status in ("L", "P", "D"):
result = self._query(
"stock_basic",
{"exchange": "", "list_status": status},
"ts_code,symbol,name,industry,list_status,list_date,delist_date",
unit="entity",
)
rows.extend(result.rows)
return ProviderResult(
tuple(rows), _metadata(self.source, "entity", min(len(rows) / 5300, 1))
)
def daily(self, entity_type: str, identifier: str, end_date: str) -> ProviderResult:
api_name = "index_daily" if entity_type == "index" else "daily"
if entity_type in {"sector", "theme"}:
api_name = "ths_daily"
end = datetime.strptime(_compact(end_date), "%Y%m%d")
start = (end - timedelta(days=380)).strftime("%Y%m%d")
return self._query(
api_name,
{"ts_code": identifier, "start_date": start, "end_date": end.strftime("%Y%m%d")},
"ts_code,trade_date,open,high,low,close,vol,amount,pct_chg",
unit="yuan/share",
adjustment="unadjusted",
)
def minute(self, entity_type: str, identifier: str, trade_date: str) -> ProviderResult:
if entity_type != "stock":
raise ProviderError("Tushare minute charts only support stocks")
date = _display(trade_date)
return self._query(
"stk_mins",
{
"ts_code": identifier,
"freq": "1min",
"start_date": f"{date} 09:30:00",
"end_date": f"{date} 15:00:00",
},
"ts_code,trade_time,open,high,low,close,vol,amount",
unit="yuan/share",
)
def _query(
self,
api_name: str,
params: dict[str, Any],
fields: str,
*,
unit: str,
adjustment: str = "not_applicable",
) -> ProviderResult:
if not self.configured:
raise ProviderError("行情服务尚未配置")
token = self._token()
if not token:
raise ProviderError("行情服务尚未配置")
body = json.dumps(
{"api_name": api_name, "token": token, "params": params, "fields": fields},
ensure_ascii=False,
).encode("utf-8")
request = urllib.request.Request(
self.url,
data=body,
headers={"Content-Type": "application/json", "User-Agent": "XiaobaiReview/2"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=self._timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
raise ProviderError("行情服务请求失败") from exc
if payload.get("code") not in (None, 0):
raise ProviderError(str(payload.get("msg") or "行情服务拒绝请求"))
data = payload.get("data") or {}
columns = data.get("fields") or []
rows = tuple(dict(zip(columns, item, strict=False)) for item in data.get("items") or [])
return ProviderResult(rows, _metadata(self.source, unit, 1 if rows else 0, adjustment))
def _token(self) -> str:
return str(self._token_provider() or "").strip()
def _metadata(
source: DataSource, unit: str, coverage: float, adjustment: str = "not_applicable"
) -> ObservationMetadata:
return ObservationMetadata(
source=source,
observed_at=datetime.now(SHANGHAI),
unit=unit,
adjustment=adjustment,
freshness_seconds=0,
coverage=coverage,
state=SnapshotState.ARCHIVE,
usage=DataUsage.CALCULATION,
)
def _compact(value: str) -> str:
normalized = value.replace("-", "")
if len(normalized) != 8 or not normalized.isdigit():
raise ProviderError("日期格式无效")
return normalized
def _display(value: str) -> str:
compact = _compact(value)
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from backend.data.contracts import ProviderResult
class DataQualityError(RuntimeError):
pass
MINIMUM_COVERAGE = {
"calendar": 1.0,
"entities": 0.98,
"daily_chart": 1.0,
"minute_chart": 1.0,
}
def require_quality(dataset: str, result: ProviderResult) -> ProviderResult:
minimum = MINIMUM_COVERAGE.get(dataset, 1.0)
if not result.rows:
raise DataQualityError(f"{dataset} returned no real observations")
if result.metadata.coverage < minimum:
raise DataQualityError(
f"{dataset} coverage {result.metadata.coverage:.3f} is below {minimum:.3f}"
)
return result
+215
View File
@@ -0,0 +1,215 @@
from __future__ import annotations
import json
import re
import sqlite3
from datetime import datetime
from typing import Any
from backend.data.contracts import MarketEntity
class MarketRepository:
def replace_calendar(
self,
connection: sqlite3.Connection,
rows: tuple[dict[str, Any], ...],
source: str,
observed_at: str,
) -> None:
connection.executemany(
"""
INSERT INTO trading_days (trade_date, is_open, previous_open_date, source, observed_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(trade_date) DO UPDATE SET
is_open = excluded.is_open,
previous_open_date = excluded.previous_open_date,
source = excluded.source,
observed_at = excluded.observed_at
""",
[
(
_display(str(row.get("cal_date") or "")),
1 if int(row.get("is_open") or 0) == 1 else 0,
_display(str(row.get("pretrade_date") or "")) or None,
source,
observed_at,
)
for row in rows
if _display(str(row.get("cal_date") or ""))
],
)
def replace_stocks(
self,
connection: sqlite3.Connection,
rows: tuple[dict[str, Any], ...],
source: str,
observed_at: str,
) -> None:
connection.executemany(
"""
INSERT INTO market_entities (
entity_type, identifier, code, name, search_key,
sector, active, source, observed_at
)
VALUES ('stock', ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(entity_type, identifier) DO UPDATE SET
code = excluded.code,
name = excluded.name,
search_key = excluded.search_key,
sector = excluded.sector,
active = excluded.active,
source = excluded.source,
observed_at = excluded.observed_at
""",
[
(
str(row.get("ts_code") or "").upper(),
str(row.get("symbol") or ""),
str(row.get("name") or "").strip(),
_search_key(row),
str(row.get("industry") or "").strip() or None,
0 if row.get("list_status") == "D" else 1,
source,
observed_at,
)
for row in rows
if row.get("ts_code") and row.get("symbol") and row.get("name")
],
)
def search(
self, connection: sqlite3.Connection, query: str, limit: int = 32
) -> tuple[MarketEntity, ...]:
normalized = _normalize(query)
if not normalized:
return ()
rows = connection.execute(
"""
SELECT entity_type, identifier, code, name, sector
FROM market_entities
WHERE active = 1 AND search_key LIKE ?
ORDER BY
CASE WHEN code = ? THEN 0 WHEN name = ? THEN 1 WHEN code LIKE ? THEN 2 ELSE 3 END,
entity_type, name
LIMIT ?
""",
(f"%{normalized}%", normalized, query.strip(), f"{normalized}%", limit),
).fetchall()
return tuple(MarketEntity(**dict(row)) for row in rows)
def entity(
self, connection: sqlite3.Connection, entity_type: str, identifier: str
) -> MarketEntity | None:
row = connection.execute(
"""
SELECT entity_type, identifier, code, name, sector
FROM market_entities WHERE entity_type = ? AND identifier = ? AND active = 1
""",
(entity_type, identifier),
).fetchone()
return MarketEntity(**dict(row)) if row else None
def open_dates(
self, connection: sqlite3.Connection, through: str, limit: int = 12
) -> tuple[str, ...]:
return tuple(
str(row["trade_date"])
for row in connection.execute(
"""
SELECT trade_date FROM trading_days
WHERE is_open = 1 AND trade_date <= ?
ORDER BY trade_date DESC LIMIT ?
""",
(through, limit),
)
)
def latest_summary(self, connection: sqlite3.Connection, through: str) -> sqlite3.Row | None:
return connection.execute(
"SELECT * FROM market_summaries WHERE trade_date <= ? ORDER BY trade_date DESC LIMIT 1",
(through,),
).fetchone()
def save_chart(
self,
connection: sqlite3.Connection,
*,
entity_type: str,
identifier: str,
interval: str,
trade_date: str,
observed_at: str,
source: str,
usage: str,
adjustment: str,
coverage: float,
payload: dict[str, Any],
) -> None:
connection.execute(
"""
INSERT INTO chart_series
(entity_type, identifier, interval, trade_date, observed_at, source, usage,
adjustment, coverage, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(entity_type, identifier, interval, trade_date) DO UPDATE SET
observed_at = excluded.observed_at,
source = excluded.source,
usage = excluded.usage,
adjustment = excluded.adjustment,
coverage = excluded.coverage,
payload_json = excluded.payload_json,
created_at = excluded.created_at
""",
(
entity_type,
identifier,
interval,
trade_date,
observed_at,
source,
usage,
adjustment,
coverage,
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
datetime.now().astimezone().isoformat(timespec="seconds"),
),
)
def chart(
self, connection: sqlite3.Connection, entity_type: str, identifier: str, interval: str
) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT * FROM chart_series
WHERE entity_type = ? AND identifier = ? AND interval = ?
ORDER BY trade_date DESC LIMIT 1
""",
(entity_type, identifier, interval),
).fetchone()
def _display(value: str) -> str:
compact = value.replace("-", "")
if len(compact) != 8 or not compact.isdigit():
return ""
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
def _normalize(value: str) -> str:
return re.sub(r"\s+", "", value).casefold()
def _search_key(row: dict[str, Any]) -> str:
return " ".join(
filter(
None,
(
_normalize(str(row.get("symbol") or "")),
_normalize(str(row.get("ts_code") or "")),
_normalize(str(row.get("name") or "")),
_normalize(str(row.get("industry") or "")),
),
)
)
@@ -0,0 +1,114 @@
from __future__ import annotations
import sqlite3
from backend.database.migrations.runner import Migration
def upgrade(connection: sqlite3.Connection) -> None:
connection.execute(
"""
CREATE TABLE trading_days (
trade_date TEXT PRIMARY KEY,
is_open INTEGER NOT NULL CHECK (is_open IN (0, 1)),
previous_open_date TEXT,
source TEXT NOT NULL,
observed_at TEXT NOT NULL
)
"""
)
connection.execute(
"""
CREATE TABLE market_entities (
entity_type TEXT NOT NULL,
identifier TEXT NOT NULL,
code TEXT NOT NULL,
name TEXT NOT NULL,
search_key TEXT NOT NULL,
sector TEXT,
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
source TEXT NOT NULL,
observed_at TEXT NOT NULL,
PRIMARY KEY (entity_type, identifier)
)
"""
)
connection.execute(
"""
CREATE INDEX market_entities_search_idx
ON market_entities(active, search_key, entity_type)
"""
)
connection.execute(
"""
INSERT INTO market_entities (
entity_type, identifier, code, name, search_key,
sector, active, source, observed_at
) VALUES
(
'index', '000001.SH', '000001', '上证指数',
'000001 上证指数 shanghai', NULL, 1, 'local', '2026-07-30T00:00:00+08:00'
),
(
'index', '399001.SZ', '399001', '深证成指',
'399001 深证成指 shenzhen', NULL, 1, 'local', '2026-07-30T00:00:00+08:00'
),
(
'index', '399006.SZ', '399006', '创业板指',
'399006 创业板指 chinext', NULL, 1, 'local', '2026-07-30T00:00:00+08:00'
)
"""
)
connection.execute(
"""
CREATE TABLE market_summaries (
trade_date TEXT PRIMARY KEY,
observed_at TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('realtime', 'final', 'archive')),
source TEXT NOT NULL,
coverage REAL NOT NULL CHECK (coverage >= 0 AND coverage <= 1),
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL
)
"""
)
connection.execute(
"""
CREATE TABLE chart_series (
entity_type TEXT NOT NULL,
identifier TEXT NOT NULL,
interval TEXT NOT NULL CHECK (interval IN ('day', 'minute')),
trade_date TEXT NOT NULL,
observed_at TEXT NOT NULL,
source TEXT NOT NULL,
usage TEXT NOT NULL CHECK (usage IN ('display', 'calculation')),
adjustment TEXT NOT NULL,
coverage REAL NOT NULL CHECK (coverage >= 0 AND coverage <= 1),
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (entity_type, identifier, interval, trade_date)
)
"""
)
connection.execute(
"""
CREATE INDEX chart_series_latest_idx
ON chart_series(entity_type, identifier, interval, trade_date DESC)
"""
)
def downgrade(connection: sqlite3.Connection) -> None:
connection.execute("DROP TABLE chart_series")
connection.execute("DROP TABLE market_summaries")
connection.execute("DROP TABLE market_entities")
connection.execute("DROP TABLE trading_days")
MIGRATION = Migration(
version=3,
name="create_market_foundation",
signature="market:v1:calendar-entities-summary-chart-provenance",
upgrade=upgrade,
downgrade=downgrade,
)
+2 -1
View File
@@ -1,5 +1,6 @@
from backend.database.migrations.m0001_accounts import MIGRATION as ACCOUNTS
from backend.database.migrations.m0002_model_pool import MIGRATION as MODEL_POOL
from backend.database.migrations.m0003_market_foundation import MIGRATION as MARKET_FOUNDATION
from backend.database.migrations.runner import Migration
MIGRATIONS: tuple[Migration, ...] = (ACCOUNTS, MODEL_POOL)
MIGRATIONS: tuple[Migration, ...] = (ACCOUNTS, MODEL_POOL, MARKET_FOUNDATION)
+3
View File
@@ -0,0 +1,3 @@
from backend.features.market.service import MarketService
__all__ = ["MarketService"]
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
from typing import Annotated, Literal
from fastapi import APIRouter, Path, Query, Request
from backend.features.accounts.auth import AdminWritePrincipal, AuthenticatedPrincipal
from backend.features.market.schemas import (
ChartResponse,
MarketSummaryResponse,
ReferenceSyncResponse,
SearchResponse,
TradeContextResponse,
)
router = APIRouter(prefix="/market", tags=["market"])
@router.get("/context", response_model=TradeContextResponse)
def context(
request: Request,
_principal: AuthenticatedPrincipal,
requested_date: Annotated[str | None, Query(alias="date")] = None,
) -> dict:
return request.app.state.container.market.context(requested_date)
@router.get("/summary", response_model=MarketSummaryResponse)
def summary(
request: Request,
_principal: AuthenticatedPrincipal,
requested_date: Annotated[str | None, Query(alias="date")] = None,
) -> dict:
return request.app.state.container.market.summary(requested_date)
@router.get("/search", response_model=SearchResponse)
def search(
request: Request,
_principal: AuthenticatedPrincipal,
query: Annotated[str, Query(alias="q", max_length=80)] = "",
) -> dict:
return request.app.state.container.market.search(query)
@router.get("/entities/{entity_type}/{identifier}/charts/{interval}", response_model=ChartResponse)
def chart(
request: Request,
_principal: AuthenticatedPrincipal,
entity_type: Annotated[Literal["stock", "sector", "theme", "index"], Path()],
identifier: Annotated[str, Path(min_length=1, max_length=40)],
interval: Annotated[Literal["day", "minute"], Path()],
) -> dict:
return request.app.state.container.market.chart(entity_type, identifier, interval)
@router.post("/reference-sync", response_model=ReferenceSyncResponse)
def refresh_reference(request: Request, _principal: AdminWritePrincipal) -> dict[str, int | str]:
return request.app.state.container.market.refresh_reference()
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
class TradeContextResponse(BaseModel):
requested_date: str
actual_date: str | None
previous_date: str | None
observed_at: datetime | None
state: str | None
carried_forward: bool
message: str
class MarketSummaryResponse(BaseModel):
context: TradeContextResponse
values: dict[str, Any] | None
class SearchResultResponse(BaseModel):
entity_type: Literal["stock", "sector", "theme", "index"]
identifier: str
code: str
name: str
sector: str | None
class SearchGroupResponse(BaseModel):
entity_type: Literal["stock", "sector", "theme", "index"]
label: str
items: list[SearchResultResponse]
class SearchResponse(BaseModel):
query: str
groups: list[SearchGroupResponse]
class ChartPointResponse(BaseModel):
time: str
open: float
high: float
low: float
close: float
volume: float
amount: float
average: float | None
class ChartResponse(BaseModel):
entity_type: str
identifier: str
code: str
name: str
interval: Literal["day", "minute"]
trade_date: str
observed_at: datetime
previous_close: float | None
range_start: str | None
range_end: str | None
points: list[ChartPointResponse]
class ReferenceSyncResponse(BaseModel):
calendar_days: int = Field(ge=1)
entities: int = Field(ge=1)
observed_at: datetime
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
from typing import Any
from backend.data.gateway import DataGateway, MarketDataUnavailable
from backend.data.providers.base import ProviderError
from backend.data.quality import DataQualityError
from backend.http.errors import AppError
class MarketService:
def __init__(self, gateway: DataGateway) -> None:
self._gateway = gateway
def context(self, requested_date: str | None = None) -> dict[str, Any]:
context = self._call(self._gateway.trade_context, requested_date)
return _context(context)
def summary(self, requested_date: str | None = None) -> dict[str, Any]:
result = self._call(self._gateway.summary, requested_date)
return {"context": _context(result["context"]), "values": result["values"]}
def search(self, query: str) -> dict[str, Any]:
normalized = " ".join(query.split())
items = self._gateway.search(normalized) if normalized else ()
labels = {"stock": "股票", "sector": "板块", "theme": "题材", "index": "指数"}
groups = []
for entity_type in ("stock", "sector", "theme", "index"):
groups.append(
{
"entity_type": entity_type,
"label": labels[entity_type],
"items": [
{
"entity_type": item.entity_type,
"identifier": item.identifier,
"code": item.code,
"name": item.name,
"sector": item.sector,
}
for item in items
if item.entity_type == entity_type
],
}
)
return {"query": normalized, "groups": groups}
def chart(self, entity_type: str, identifier: str, interval: str) -> dict[str, Any]:
series = self._call(self._gateway.chart, entity_type, identifier, interval)
return {
"entity_type": series.entity.entity_type,
"identifier": series.entity.identifier,
"code": series.entity.code,
"name": series.entity.name,
"interval": series.interval,
"trade_date": series.trade_date,
"observed_at": series.metadata.observed_at,
"previous_close": series.previous_close,
"range_start": "09:30" if interval == "minute" else None,
"range_end": "15:00" if interval == "minute" else None,
"points": [
{
"time": point.time,
"open": point.open,
"high": point.high,
"low": point.low,
"close": point.close,
"volume": point.volume,
"amount": point.amount,
"average": point.average,
}
for point in series.points
],
}
def refresh_reference(self) -> dict[str, int | str]:
return self._call(self._gateway.refresh_reference)
@staticmethod
def _call(function, *args):
try:
return function(*args)
except (MarketDataUnavailable, ProviderError, DataQualityError) as exc:
raise AppError("market_data_unavailable", str(exc), 503) from exc
def _context(context) -> dict[str, Any]:
return {
"requested_date": context.requested_date,
"actual_date": context.actual_date,
"previous_date": context.previous_date,
"observed_at": context.observed_at,
"state": context.state.value if context.state else None,
"carried_forward": context.carried_forward,
"message": context.message,
}
+2
View File
@@ -1,8 +1,10 @@
from fastapi import APIRouter
from backend.features.accounts.routes import router as accounts_router
from backend.features.market.routes import router as market_router
from backend.http.routes.health import router as health_router
api_router = APIRouter()
api_router.include_router(health_router)
api_router.include_router(accounts_router)
api_router.include_router(market_router)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 131 KiB

+49
View File
@@ -0,0 +1,49 @@
# 阶段5验收记录
## 范围
本阶段建立唯一数据网关、数据源准入策略、交易日期与真实快照契约、标的目录、全局搜索、日K和分时图基础。情绪计算、五类股池和完整个股业务详情仍分别属于阶段6及后续阶段,本阶段不提前复制旧业务。
## 数据真相
- 每条可持久化图表序列保留标的、真实交易日、观测时间、来源、用途、单位、复权、覆盖率和实时/最终/归档状态。
- Tushare负责交易日历、股票目录和日线;iFinD优先用于展示图表;东方财富只允许进入展示分时兜底,策略和情绪计算会被策略层拒绝。
- 今天没有有效开高低收、成交量和成交额时,日K删除空的今日柱,继续显示最后真实交易日。
- 历史快照只按实际交易日返回;沿用旧快照时,接口同时返回请求日期、实际数据日期和沿用说明。
- 计算数据覆盖率不达标时失败关闭;从未同步时显示等待首次同步,不产生模拟数据。
## 搜索与图表
- `Ctrl+K`搜索经唯一浏览器API出口请求,160毫秒防抖,按股票、板块、题材、指数固定分组。
- 支持键盘上下循环、Enter打开、Escape关闭,并区分无输入、加载、空结果和失败。
- 桌面搜索结果提供最新日K预览,可切换分时;点击进入共用行情详情基础页。移动端点击进入详情,不依赖Hover。
- 日K上涨柱为空心且影线不穿实体,下跌柱为实心;分时提供昨收零轴和均价线,横轴契约固定09:30至15:00。
- 普通用户响应不包含供应商名称;行情管理保留管理员可见的凭据与基础资料同步入口。
## 减法证据
- 未复制旧`TushareClient``IfindHttpClient``MarketChartClient`;只迁移通用协议、授权刷新和必要归一化规则。
- 所有外部源通过一个`DataGateway`和一份`DataSourcePolicy`进入系统;页面没有直接访问供应商。
- 搜索预览与详情复用同一个图表组件和同一接口,不创建股票、板块、题材、指数四套图表实现。
- 新增后端最长文件为379行,未超过章程400行目标;CSS色值只存在于`tokens.css`
- 未将测试行情写入产品数据库,浏览器视觉样本由Playwright路由固定,仅用于前端验收。
## 自动验证
- Ruff:通过。
- pytest:46项通过,覆盖数据源准入、快照沿用、盘前空K线、iFinD顶层表格与过期授权刷新、搜索权限与固定分组、图表响应不泄露来源。
- Vue类型检查:通过。
- Vitest2个文件、5项通过。
- Vite生产构建:通过。
- Playwright3项通过,覆盖既有Shell回归,以及摘要、搜索、日K预览、详情、分时零轴、日夜主题、1920×1080和390×844视口。
- 密钥扫描:已提供账号密码与令牌均未进入`next/`;组件CSS未发现令牌外色值。
## 截图
- [日间搜索与日K预览 1920×1080](search-preview-light-1920x1080.jpg)
- [夜间分时详情 1920×1080](entity-detail-dark-1920x1080.jpg)
- [夜间移动分时详情 390×844](entity-detail-dark-390x844.jpg)
## 后续入口
阶段6将使用本阶段的交易日期、摘要存储和数据质量门实现情绪周期与五类股池。只有阶段6的确定性行情任务可以写入正式市场摘要,展示兜底源不能借图表接口进入计算。
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+6
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from "vue-router";
import SystemManagementView from "./views/SystemManagementView.vue";
import WorkspaceView from "./views/WorkspaceView.vue";
import EntityDetailView from "./views/EntityDetailView.vue";
import { findWorkspace } from "./workspaceRegistry";
export default createRouter({
@@ -15,6 +16,11 @@ export default createRouter({
beforeEnter: (to) => (findWorkspace(String(to.params.workspace)) ? true : "/workspace/emotion"),
},
{ path: "/system", name: "system", component: SystemManagementView },
{
path: "/market/:entityType/:identifier",
name: "entity-detail",
component: EntityDetailView,
},
{ path: "/:pathMatch(.*)*", redirect: "/workspace/emotion" },
],
});
+6 -1
View File
@@ -4,6 +4,7 @@ import { onBeforeUnmount, onMounted } from "vue";
import DialogHost from "../../shared/components/DialogHost.vue";
import ToastHost from "../../shared/components/ToastHost.vue";
import { useUiStore } from "../../shared/stores/ui";
import { useMarketStore } from "../../shared/stores/market";
import DesktopSidebar from "./DesktopSidebar.vue";
import MarketStrip from "./MarketStrip.vue";
import MobileNav from "./MobileNav.vue";
@@ -11,6 +12,7 @@ import StatusBar from "./StatusBar.vue";
import TopBar from "./TopBar.vue";
const ui = useUiStore();
const market = useMarketStore();
function globalShortcut(event: KeyboardEvent): void {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "k") return;
@@ -22,7 +24,10 @@ function globalShortcut(event: KeyboardEvent): void {
ui.openDialog("search");
}
onMounted(() => window.addEventListener("keydown", globalShortcut));
onMounted(() => {
window.addEventListener("keydown", globalShortcut);
void market.load();
});
onBeforeUnmount(() => window.removeEventListener("keydown", globalShortcut));
</script>
+51 -11
View File
@@ -1,28 +1,68 @@
<script setup lang="ts">
import { ref } from "vue";
import { computed, ref } from "vue";
import { useMarketStore } from "../../shared/stores/market";
const expanded = ref(false);
const cells = ["市场情绪", "涨停", "跌停", "炸板", "封板率", "两市成交", "数据日期"];
const market = useMarketStore();
const values = computed(() => market.summary?.values ?? {});
const cells = computed(() => [
["市场情绪", display("temperature")],
["涨停", display("limit_up")],
["跌停", display("limit_down")],
["炸板", display("broken")],
["封板率", percent("seal_rate")],
["两市成交", amount("amount")],
["数据日期", observedTime()],
]);
function display(key: string): string {
const value = values.value[key];
return typeof value === "number" || typeof value === "string" ? String(value) : "";
}
function percent(key: string): string {
const value = values.value[key];
return typeof value === "number" ? `${value.toFixed(1)}%` : "";
}
function amount(key: string): string {
const value = values.value[key];
return typeof value === "number" ? `${(value / 100_000_000).toFixed(2)} 亿` : "";
}
function observedTime(): string {
const observed = market.summary?.context.observed_at;
if (!observed) return "";
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(observed));
}
</script>
<template>
<section class="market-strip" aria-label="市场摘要">
<div class="market-strip-row">
<span class="market-emotion"><span class="emotion-dot" aria-hidden="true"></span>市场情绪</span>
<span>涨停</span>
<span>跌停</span>
<span>炸板</span>
<span>封板率</span>
<span>两市成交</span>
<span class="faint">等待最新行情</span>
<span>涨停 {{ display("limit_up") }}</span>
<span>跌停 {{ display("limit_down") }}</span>
<span>炸板 {{ display("broken") }}</span>
<span>封板率 {{ percent("seal_rate") }}</span>
<span>两市成交 {{ amount("amount") }}</span>
<span class="faint">{{ market.loading ? "正在读取行情" : observedTime() || market.summary?.context.message || market.error }}</span>
<button class="market-toggle" type="button" :aria-expanded="expanded" @click="expanded = !expanded">
{{ expanded ? "收起详情" : "展开详情" }}
</button>
</div>
<div v-if="expanded" class="market-details">
<div v-for="cell in cells" :key="cell" class="market-cell">
<div class="market-cell-label">{{ cell }}</div>
<div class="market-cell-value"></div>
<div v-for="cell in cells" :key="cell[0]" class="market-cell">
<div class="market-cell-label">{{ cell[0] }}</div>
<div class="market-cell-value">{{ cell[1] }}</div>
</div>
</div>
</section>
+2 -1
View File
@@ -17,6 +17,7 @@ const menuRoot = ref<HTMLElement | null>(null);
const title = computed(() => {
if (route.name === "system") return "系统管理";
if (route.name === "entity-detail") return "行情详情";
return findWorkspace(String(route.params.workspace ?? "emotion"))?.title ?? "小白复盘";
});
@@ -38,7 +39,7 @@ function changeDate(event: Event): void {
!Number.isNaN(parsed.valueOf()) &&
parsed.toISOString().slice(0, 10) === value
) {
market.selectedDate = value;
void market.selectDate(value);
return;
}
input.value = market.selectedDate;
@@ -16,6 +16,9 @@ const statuses = ref<CredentialStatus[]>([]);
const values = reactive<Record<string, string>>({});
const loading = ref(true);
const errorMessage = ref("");
const syncing = ref(false);
const syncResult = ref("");
const syncError = ref("");
async function load(): Promise<void> {
loading.value = true;
@@ -45,6 +48,23 @@ async function save(name: string): Promise<void> {
}
}
async function syncReference(): Promise<void> {
syncing.value = true;
syncError.value = "";
syncResult.value = "";
try {
const result = await api.post<{ calendar_days: number; entities: number }>(
"/market/reference-sync",
);
syncResult.value = `已同步 ${result.calendar_days} 个日历日期、${result.entities} 个股票条目`;
ui.showToast("基础行情资料已同步");
} catch (error) {
syncError.value = error instanceof Error ? error.message : "同步失败,请稍后重试。";
} finally {
syncing.value = false;
}
}
onMounted(load);
</script>
@@ -65,6 +85,17 @@ onMounted(load);
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
</div>
</section>
<section class="card">
<header class="card-header"><h2>基础行情资料</h2><span class="faint">交易日历与股票目录</span></header>
<div class="card-body disabled-row">
<p class="muted">保存有效行情凭据后执行同步结果用于日期判断和全局搜索</p>
<span v-if="syncResult" class="up">{{ syncResult }}</span>
<span v-if="syncError" class="field-error" role="alert">{{ syncError }}</span>
<button class="btn" type="button" :disabled="syncing" @click="syncReference">
{{ syncing ? "正在同步" : "同步基础资料" }}
</button>
</div>
</section>
<section class="card">
<header class="card-header"><h2>历史数据回补</h2><span class="tag">暂不可用</span></header>
<div class="card-body disabled-row">
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute } from "vue-router";
import type { MarketEntity } from "../../shared/api/market";
import MarketPreviewPanel from "../../shared/market/MarketPreviewPanel.vue";
const route = useRoute();
const entity = computed<MarketEntity>(() => ({
entity_type: String(route.params.entityType) as MarketEntity["entity_type"],
identifier: String(route.params.identifier),
code: String(route.query.code ?? String(route.params.identifier).split(".")[0]),
name: String(route.query.name ?? route.params.identifier),
sector: route.query.sector ? String(route.query.sector) : null,
}));
</script>
<template>
<main class="page-frame entity-detail-page">
<header class="page-header entity-heading">
<div><h1>{{ entity.name }}</h1><p class="page-subtitle">{{ entity.code }}<template v-if="entity.sector"> · {{ entity.sector }}</template></p></div>
<span class="tag">最新真实行情</span>
</header>
<MarketPreviewPanel :entity="entity" class="card entity-chart" />
</main>
</template>
+1
View File
@@ -10,6 +10,7 @@ import "./shared/styles/components.css";
import "./shared/styles/shell.css";
import "./shared/styles/auth.css";
import "./shared/styles/account.css";
import "./shared/styles/market.css";
import "./shared/styles/system.css";
import "./shared/styles/mobile.css";
@@ -1,19 +1,100 @@
<script setup lang="ts">
import { ref } from "vue";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { marketApi, type MarketEntity, type SearchGroup } from "../api/market";
import MarketPreviewPanel from "../market/MarketPreviewPanel.vue";
import { useUiStore } from "../stores/ui";
const router = useRouter();
const ui = useUiStore();
const query = ref("");
const groups = ref<SearchGroup[]>([]);
const loading = ref(false);
const error = ref("");
const activeIndex = ref(0);
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
let sequence = 0;
const flatItems = computed(() => groups.value.flatMap((group) => group.items));
const active = computed(() => flatItems.value[activeIndex.value] ?? null);
watch(query, (value) => {
if (debounceTimer) clearTimeout(debounceTimer);
const normalized = value.trim();
if (!normalized) {
groups.value = [];
loading.value = false;
error.value = "";
return;
}
debounceTimer = setTimeout(() => void search(normalized), 160);
});
async function search(value: string): Promise<void> {
const current = ++sequence;
loading.value = true;
error.value = "";
try {
const response = await marketApi.search(value);
if (current === sequence) {
groups.value = response.groups;
activeIndex.value = 0;
}
} catch (reason) {
if (current === sequence) error.value = reason instanceof Error ? reason.message : "搜索失败";
} finally {
if (current === sequence) loading.value = false;
}
}
function select(item: MarketEntity): void {
ui.closeDialog();
void router.push({
name: "entity-detail",
params: { entityType: item.entity_type, identifier: item.identifier },
query: { code: item.code, name: item.name, sector: item.sector ?? undefined },
});
}
function keydown(event: KeyboardEvent): void {
if (!flatItems.value.length) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const offset = event.key === "ArrowDown" ? 1 : -1;
activeIndex.value = (activeIndex.value + offset + flatItems.value.length) % flatItems.value.length;
} else if (event.key === "Enter" && active.value) {
event.preventDefault();
select(active.value);
}
}
function itemIndex(item: MarketEntity): number {
return flatItems.value.findIndex((candidate) => candidate.identifier === item.identifier && candidate.entity_type === item.entity_type);
}
onBeforeUnmount(() => debounceTimer && clearTimeout(debounceTimer));
</script>
<template>
<div class="search-panel">
<div class="search-panel" @keydown="keydown">
<label class="sr-only" for="global-search">搜索股票板块题材或指数</label>
<input id="global-search" v-model="query" class="input search-input" autofocus placeholder="搜索股票、板块、题材或指数" />
<div class="search-categories" aria-label="搜索结果分类">
<span>股票</span><span>板块</span><span>题材</span><span>指数</span>
</div>
<div class="search-empty">
<p>{{ query ? "当前没有匹配结果" : "输入代码或名称开始搜索" }}</p>
<span>Ctrl+K</span>
<input id="global-search" v-model="query" class="input search-input" autofocus placeholder="搜索股票、板块、题材或指数" autocomplete="off" />
<div class="search-layout">
<div class="search-results" aria-live="polite">
<div v-if="!query" class="search-empty"><p>输入代码或名称开始搜索</p><span>Ctrl+K</span></div>
<div v-else-if="loading" class="search-empty"><p>正在搜索</p></div>
<div v-else-if="error" class="search-empty"><p>{{ error }}</p></div>
<div v-else-if="!flatItems.length" class="search-empty"><p>当前没有匹配结果</p></div>
<template v-else>
<section v-for="group in groups.filter((item) => item.items.length)" :key="group.entity_type" class="search-group">
<h3>{{ group.label }}</h3>
<button v-for="item in group.items" :key="item.identifier" type="button" class="search-result" :class="{ active: itemIndex(item) === activeIndex }" @mouseenter="activeIndex = itemIndex(item)" @focus="activeIndex = itemIndex(item)" @click="select(item)">
<span class="result-code">{{ item.code }}</span><strong>{{ item.name }}</strong><span>{{ item.sector }}</span>
</button>
</section>
</template>
</div>
<MarketPreviewPanel class="search-preview" :entity="active" />
</div>
</div>
</template>
+72
View File
@@ -0,0 +1,72 @@
import { api } from "./client";
export type TradeContext = {
requested_date: string;
actual_date: string | null;
previous_date: string | null;
observed_at: string | null;
state: "realtime" | "final" | "archive" | null;
carried_forward: boolean;
message: string;
};
export type MarketSummary = {
context: TradeContext;
values: Record<string, unknown> | null;
};
export type MarketEntity = {
entity_type: "stock" | "sector" | "theme" | "index";
identifier: string;
code: string;
name: string;
sector: string | null;
};
export type SearchGroup = {
entity_type: MarketEntity["entity_type"];
label: string;
items: MarketEntity[];
};
export type SearchResults = { query: string; groups: SearchGroup[] };
export type ChartPoint = {
time: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
amount: number;
average: number | null;
};
export type ChartSeries = {
entity_type: MarketEntity["entity_type"];
identifier: string;
code: string;
name: string;
interval: "day" | "minute";
trade_date: string;
observed_at: string;
previous_close: number | null;
range_start: string | null;
range_end: string | null;
points: ChartPoint[];
};
export const marketApi = {
summary(date?: string): Promise<MarketSummary> {
const query = date ? `?date=${encodeURIComponent(date)}` : "";
return api.get<MarketSummary>(`/market/summary${query}`);
},
search(query: string): Promise<SearchResults> {
return api.get<SearchResults>(`/market/search?q=${encodeURIComponent(query)}`);
},
chart(entity: MarketEntity, interval: "day" | "minute"): Promise<ChartSeries> {
const type = encodeURIComponent(entity.entity_type);
const identifier = encodeURIComponent(entity.identifier);
return api.get<ChartSeries>(`/market/entities/${type}/${identifier}/charts/${interval}`);
},
};
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed } from "vue";
import type { ChartSeries } from "../api/market";
const props = defineProps<{ series: ChartSeries }>();
const width = 720;
const height = 260;
const inset = 18;
const values = computed(() => props.series.points.flatMap((point) => [point.high, point.low]));
const minimum = computed(() => Math.min(...values.value));
const maximum = computed(() => Math.max(...values.value));
const span = computed(() => Math.max(maximum.value - minimum.value, maximum.value * 0.01, 0.01));
const step = computed(() => (width - inset * 2) / Math.max(props.series.points.length, 1));
const candleWidth = computed(() => Math.max(2, Math.min(9, step.value * 0.58)));
function x(index: number): number {
return inset + step.value * (index + 0.5);
}
function y(value: number): number {
return inset + ((maximum.value - value) / span.value) * (height - inset * 2);
}
const linePath = computed(() =>
props.series.points.map((point, index) => `${index ? "L" : "M"}${x(index)},${y(point.close)}`).join(" "),
);
const averagePath = computed(() =>
props.series.points
.filter((point) => point.average !== null)
.map((point, index) => `${index ? "L" : "M"}${x(index)},${y(point.average ?? point.close)}`)
.join(" "),
);
const zeroY = computed(() =>
props.series.previous_close === null ? null : y(props.series.previous_close),
);
</script>
<template>
<svg class="market-chart" :viewBox="`0 0 ${width} ${height}`" role="img" :aria-label="`${series.name}${series.interval === 'day' ? '日K' : '分时'}`">
<line v-if="series.interval === 'minute' && zeroY !== null" class="chart-zero" :x1="inset" :x2="width - inset" :y1="zeroY" :y2="zeroY" />
<template v-if="series.interval === 'day'">
<g v-for="(point, index) in series.points" :key="point.time" :class="point.close >= point.open ? 'candle-up' : 'candle-down'">
<line :x1="x(index)" :x2="x(index)" :y1="y(point.high)" :y2="y(Math.max(point.open, point.close))" />
<line :x1="x(index)" :x2="x(index)" :y1="y(Math.min(point.open, point.close))" :y2="y(point.low)" />
<rect :x="x(index) - candleWidth / 2" :y="y(Math.max(point.open, point.close))" :width="candleWidth" :height="Math.max(1, Math.abs(y(point.open) - y(point.close)))" />
</g>
</template>
<template v-else>
<path class="chart-price" :d="linePath" />
<path v-if="averagePath" class="chart-average" :d="averagePath" />
</template>
</svg>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { marketApi, type ChartSeries, type MarketEntity } from "../api/market";
import MarketChart from "./MarketChart.vue";
const props = defineProps<{ entity: MarketEntity | null }>();
const interval = ref<"day" | "minute">("day");
const series = ref<ChartSeries | null>(null);
const loading = ref(false);
const error = ref("");
let requestSequence = 0;
async function load(): Promise<void> {
const entity = props.entity;
if (!entity) {
series.value = null;
return;
}
const sequence = ++requestSequence;
loading.value = true;
error.value = "";
try {
const result = await marketApi.chart(entity, interval.value);
if (sequence === requestSequence) series.value = result;
} catch (reason) {
if (sequence === requestSequence) {
series.value = null;
error.value = reason instanceof Error ? reason.message : "行情读取失败";
}
} finally {
if (sequence === requestSequence) loading.value = false;
}
}
watch(() => props.entity?.identifier, () => {
interval.value = "day";
void load();
}, { immediate: true });
watch(interval, () => void load());
</script>
<template>
<section class="market-preview">
<header v-if="entity" class="preview-header">
<div><strong>{{ entity.name }}</strong><span>{{ entity.code }}</span></div>
<div class="seg-control" aria-label="行情周期">
<button type="button" :class="{ active: interval === 'day' }" @click="interval = 'day'">日K</button>
<button type="button" :class="{ active: interval === 'minute' }" @click="interval = 'minute'">分时</button>
</div>
</header>
<div v-if="loading" class="preview-state">正在读取真实行情</div>
<div v-else-if="error" class="preview-state">{{ error }}</div>
<MarketChart v-else-if="series" :series="series" />
<div v-else class="preview-state">选择一个结果查看最新行情</div>
<footer v-if="series" class="preview-meta">
数据日期 {{ series.trade_date }}<template v-if="series.interval === 'minute'"> · 分时范围固定 09:3015:00</template>
</footer>
</section>
</template>
+27 -5
View File
@@ -1,6 +1,8 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { marketApi, type MarketSummary } from "../api/market";
function shanghaiDate(): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
@@ -12,12 +14,32 @@ function shanghaiDate(): string {
export const useMarketStore = defineStore("market", () => {
const selectedDate = ref(shanghaiDate());
const summary = ref<MarketSummary | null>(null);
const loading = ref(false);
const error = ref("");
function moveDate(offset: number): void {
const date = new Date(`${selectedDate.value}T12:00:00+08:00`);
date.setUTCDate(date.getUTCDate() + offset);
selectedDate.value = date.toISOString().slice(0, 10);
async function load(date?: string): Promise<void> {
loading.value = true;
error.value = "";
try {
summary.value = await marketApi.summary(date);
selectedDate.value = summary.value.context.actual_date ?? summary.value.context.requested_date;
} catch (reason) {
error.value = reason instanceof Error ? reason.message : "行情摘要读取失败";
} finally {
loading.value = false;
}
}
return { selectedDate, moveDate };
async function selectDate(date: string): Promise<void> {
await load(date);
}
async function moveDate(offset: number): Promise<void> {
const date = new Date(`${selectedDate.value}T12:00:00+08:00`);
date.setUTCDate(date.getUTCDate() + offset);
await load(date.toISOString().slice(0, 10));
}
return { selectedDate, summary, loading, error, load, selectDate, moveDate };
});
+61 -15
View File
@@ -103,21 +103,6 @@
min-height: var(--s-44);
}
.search-categories {
display: flex;
gap: var(--s-8);
padding-bottom: var(--s-10);
border-bottom: var(--s-1) solid var(--color-divider);
}
.search-categories span {
padding: var(--s-4) var(--s-9);
border-radius: var(--tag-radius);
color: var(--color-text-secondary);
background: var(--color-surface-muted);
font-size: var(--font-11);
}
.search-empty {
min-height: var(--s-200);
display: grid;
@@ -128,6 +113,67 @@
font-size: var(--font-12);
}
.search-layout {
min-height: var(--s-320);
display: grid;
grid-template-columns: var(--s-320) minmax(0, 1fr);
gap: var(--s-12);
}
.search-results {
max-height: var(--s-400);
overflow-y: auto;
border-right: var(--s-1) solid var(--color-divider);
padding-right: var(--s-12);
}
.search-group + .search-group {
margin-top: var(--s-12);
}
.search-group h3 {
padding: var(--s-4) var(--s-8);
color: var(--color-text-faint);
font-size: var(--font-11);
font-weight: var(--weight-600);
}
.search-result {
width: 100%;
min-height: var(--s-36);
display: grid;
grid-template-columns: var(--s-64) minmax(0, 1fr) auto;
align-items: center;
gap: var(--s-8);
padding: var(--s-6) var(--s-8);
border-radius: var(--control-radius);
color: var(--color-text-secondary);
background: var(--c-transparent);
text-align: left;
}
.search-result:hover,
.search-result.active {
color: var(--color-text);
background: var(--color-primary-soft);
}
.search-result strong {
overflow: hidden;
color: var(--color-text);
font-size: var(--font-12-5);
text-overflow: ellipsis;
white-space: nowrap;
}
.search-result span {
font-size: var(--font-11);
}
.result-code {
font-variant-numeric: tabular-nums;
}
.search-empty span {
padding: var(--s-2) var(--s-6);
border: var(--s-1) solid var(--color-border);
+126
View File
@@ -0,0 +1,126 @@
.market-preview {
min-width: 0;
display: grid;
grid-template-rows: auto minmax(var(--s-260), 1fr) auto;
overflow: hidden;
background: var(--color-surface);
}
.preview-header {
min-height: var(--s-40);
display: flex;
align-items: center;
gap: var(--s-12);
padding: var(--s-8) var(--s-12);
border-bottom: var(--s-1) solid var(--color-divider);
}
.preview-header > div:first-child {
min-width: 0;
display: flex;
align-items: baseline;
gap: var(--s-8);
}
.preview-header strong {
font-size: var(--font-14);
}
.preview-header span,
.preview-meta {
color: var(--color-text-faint);
font-size: var(--font-11);
}
.seg-control {
display: inline-flex;
gap: var(--s-2);
margin-left: auto;
padding: var(--s-2);
border-radius: var(--control-radius);
background: var(--color-surface-muted);
}
.seg-control button {
min-height: var(--s-24);
padding: var(--s-4) var(--s-8);
border-radius: var(--radius-5);
color: var(--color-text-secondary);
background: var(--c-transparent);
font-size: var(--font-11);
}
.seg-control button.active {
color: var(--color-primary);
background: var(--color-surface);
}
.market-chart {
width: 100%;
height: 100%;
min-height: var(--s-260);
padding: var(--s-8);
background: var(--color-surface);
}
.candle-up,
.candle-down {
stroke-width: var(--s-1);
}
.candle-up {
fill: var(--color-surface);
stroke: var(--color-up);
}
.candle-down {
fill: var(--color-down);
stroke: var(--color-down);
}
.chart-price,
.chart-average {
fill: none;
stroke-width: var(--s-2);
}
.chart-price {
stroke: var(--color-primary);
}
.chart-average {
stroke: var(--color-warning);
}
.chart-zero {
stroke: var(--color-text-faint);
stroke-width: var(--s-1);
stroke-dasharray: var(--s-4) var(--s-4);
}
.preview-state {
min-height: var(--s-260);
display: grid;
place-items: center;
color: var(--color-text-secondary);
font-size: var(--font-12);
}
.preview-meta {
min-height: var(--s-30);
padding: var(--s-7) var(--s-12);
border-top: var(--s-1) solid var(--color-divider);
}
.entity-heading {
justify-content: space-between;
align-items: center;
}
.entity-heading h1 {
display: inline;
}
.entity-chart {
min-height: var(--s-400);
}
@@ -114,6 +114,20 @@
grid-template-columns: minmax(0, 1fr) var(--s-64) var(--s-64);
}
.search-layout {
grid-template-columns: 1fr;
}
.search-results {
max-height: none;
border-right: 0;
padding-right: 0;
}
.search-preview {
display: none;
}
.auth-view {
align-items: start;
padding-top: var(--s-34);
+118
View File
@@ -0,0 +1,118 @@
const fs = require("node:fs");
const path = require("node:path");
const { expect, test } = require("@playwright/test");
const evidence = path.resolve(__dirname, "../../docs/evidence/stage-5");
test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true }));
async function authenticate(page) {
await page.goto("/");
await page.getByLabel("账号名").fill("stage5admin");
await page.getByLabel("密码").fill("Stage5-pass-123!");
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page.locator(".sidebar, .field-error")).toBeVisible();
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("button", { name: "注册并登录" }).click();
}
}
function chartPayload(interval) {
const daily = [
["2026-07-23", 3500, 3540, 3480, 3530],
["2026-07-24", 3530, 3568, 3510, 3552],
["2026-07-25", 3550, 3580, 3524, 3540],
["2026-07-28", 3542, 3590, 3530, 3582],
["2026-07-29", 3585, 3612, 3570, 3604],
];
const minute = [
["09:30", 3600, 3608, 3594, 3604, 3602],
["10:30", 3604, 3616, 3600, 3610, 3607],
["11:30", 3610, 3614, 3602, 3606, 3608],
["14:00", 3606, 3620, 3604, 3618, 3610],
["15:00", 3618, 3622, 3608, 3612, 3613],
];
return {
entity_type: "index",
identifier: "000001.SH",
code: "000001",
name: "上证指数",
interval,
trade_date: "2026-07-29",
observed_at: "2026-07-29T15:00:00+08:00",
previous_close: 3594,
range_start: interval === "minute" ? "09:30" : null,
range_end: interval === "minute" ? "15:00" : null,
points: (interval === "day" ? daily : minute).map((row) => ({
time: row[0],
open: row[1],
high: row[2],
low: row[3],
close: row[4],
volume: 100000,
amount: 360000000,
average: interval === "minute" ? row[5] : null,
})),
};
}
test("latest snapshot, grouped search, chart preview and entity detail", async ({ page }) => {
const consoleErrors = [];
page.on("console", (message) => {
if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) {
consoleErrors.push(message.text());
}
});
await page.route("**/api/market/summary", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
context: {
requested_date: "2026-07-30",
actual_date: "2026-07-29",
previous_date: "2026-07-28",
observed_at: "2026-07-29T15:00:00+08:00",
state: "final",
carried_forward: true,
message: "沿用最近真实收盘快照",
},
values: { temperature: 42, limit_up: 68, limit_down: 4, broken: 24, seal_rate: 73.9, amount: 1628000000000 },
}),
}),
);
await page.route("**/api/market/entities/index/000001.SH/charts/*", (route) => {
const interval = route.request().url().endsWith("/minute") ? "minute" : "day";
return route.fulfill({ contentType: "application/json", body: JSON.stringify(chartPayload(interval)) });
});
await authenticate(page);
await expect(page.locator(".market-strip-row")).toContainText("涨停 68");
await expect(page.locator(".market-strip-row")).toContainText("07/29 15:00");
await page.keyboard.press("Control+K");
const dialog = page.getByRole("dialog", { name: "全局搜索" });
await dialog.getByPlaceholder("搜索股票、板块、题材或指数").fill("上证");
await expect(dialog.getByRole("button", { name: /上证指数/ })).toBeVisible();
await expect(dialog.locator(".market-chart")).toBeVisible();
await expect(dialog).toContainText("数据日期 2026-07-29");
await page.screenshot({ path: path.join(evidence, "search-preview-light-1920x1080.jpg"), type: "jpeg", quality: 82 });
await dialog.getByRole("button", { name: /上证指数/ }).click();
await expect(page).toHaveURL(/\/market\/index\/000001.SH/);
await expect(page.getByRole("heading", { name: "上证指数" })).toBeVisible();
await page.getByRole("button", { name: "分时" }).click();
await expect(page.locator(".chart-zero")).toHaveCount(1);
await expect(page.locator(".preview-meta")).toContainText("09:3015:00");
await page.getByRole("button", { name: "夜间" }).click();
await page.screenshot({ path: path.join(evidence, "entity-detail-dark-1920x1080.jpg"), type: "jpeg", quality: 82 });
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0);
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator(".market-chart")).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0);
await page.screenshot({ path: path.join(evidence, "entity-detail-dark-390x844.jpg"), type: "jpeg", quality: 82 });
expect(consoleErrors).toEqual([]);
});
+258
View File
@@ -0,0 +1,258 @@
from __future__ import annotations
import json
from datetime import datetime
from zoneinfo import ZoneInfo
import httpx
import pytest
from backend.bootstrap.application import create_application
from backend.bootstrap.settings import Settings
from backend.data.contracts import (
DataSource,
DataUsage,
ObservationMetadata,
ProviderResult,
SnapshotState,
)
from backend.data.gateway import DataGateway
from backend.data.policy import DataPolicyError, DataSourcePolicy
from backend.data.providers.ifind import IfindProvider
from backend.data.repository import MarketRepository
from backend.database.connection import Database
from backend.database.migrations import MIGRATIONS, MigrationRunner
from tests.support import run_scenario
SHANGHAI = ZoneInfo("Asia/Shanghai")
class FakeProvider:
source = DataSource.IFIND
configured = True
def calendar(self, start_date: str, end_date: str) -> ProviderResult:
raise AssertionError("not used")
def entities(self) -> ProviderResult:
raise AssertionError("not used")
def daily(self, entity_type: str, identifier: str, end_date: str) -> ProviderResult:
return ProviderResult(
(
{
"time": "2026-07-28 15:00:00",
"open": 10,
"high": 11,
"low": 9.8,
"close": 10.8,
"volume": 1000,
"amount": 10800,
},
{
"time": "2026-07-29 15:00:00",
"open": 11,
"high": 11.2,
"low": 10.7,
"close": 11.1,
"volume": 1200,
"amount": 13320,
},
{
"time": "2026-07-30 09:15:00",
"open": 0,
"high": 0,
"low": 0,
"close": 11.1,
"volume": 0,
"amount": 0,
},
),
metadata(DataSource.IFIND, SnapshotState.ARCHIVE),
)
def minute(self, entity_type: str, identifier: str, trade_date: str) -> ProviderResult:
rows = (
{
"time": f"{trade_date} 09:30:00",
"open": 11,
"high": 11.1,
"low": 10.9,
"close": 11.05,
"volume": 100,
"amount": 1105,
"avgPrice": 11.03,
"preClose": 11,
},
)
return ProviderResult(rows, metadata(DataSource.IFIND, SnapshotState.REALTIME))
def metadata(source: DataSource, state: SnapshotState) -> ObservationMetadata:
return ObservationMetadata(
source=source,
observed_at=datetime(2026, 7, 30, 9, 15, tzinfo=SHANGHAI),
unit="yuan/share",
adjustment="forward",
freshness_seconds=0,
coverage=1,
state=state,
usage=DataUsage.DISPLAY,
)
def gateway(tmp_path) -> DataGateway:
database = Database(tmp_path / "market.db")
MigrationRunner(database).upgrade(MIGRATIONS)
repository = MarketRepository()
with database.transaction() as connection:
repository.replace_stocks(
connection,
(
{
"ts_code": "000001.SZ",
"symbol": "000001",
"name": "平安银行",
"industry": "银行",
"list_status": "L",
},
),
"tushare",
"2026-07-29T15:00:00+08:00",
)
connection.execute(
"""
INSERT INTO market_summaries
(trade_date, observed_at, state, source, coverage, payload_json, created_at)
VALUES (?, ?, 'final', 'tushare', 1, ?, ?)
""",
(
"2026-07-29",
"2026-07-29T15:00:00+08:00",
json.dumps({"limit_up": 46, "limit_down": 3}),
"2026-07-29T15:05:00+08:00",
),
)
return DataGateway(database, repository, (FakeProvider(),), DataSourcePolicy())
def test_public_sources_cannot_enter_calculations() -> None:
policy = DataSourcePolicy()
with pytest.raises(DataPolicyError):
policy.assert_allowed(DataSource.EASTMONEY, DataUsage.CALCULATION)
policy.assert_allowed(DataSource.EASTMONEY, DataUsage.DISPLAY)
def test_ifind_top_level_tables_and_expired_access_token_are_handled() -> None:
provider = IfindProvider("refresh-token", "expired-token")
calls: list[tuple[str, str]] = []
def post(endpoint, body, access, refresh=""):
calls.append((endpoint, access or refresh))
if endpoint == "get_access_token":
return {"errorcode": 0, "data": {"access_token": "fresh-token"}}
if access == "expired-token":
return {"errorcode": -1302, "errmsg": "token expired"}
return {
"errorcode": 0,
"tables": [
{
"thscode": ["000001.SZ"],
"time": ["2026-07-29 15:00:00"],
"table": {
"open": [10],
"high": [11],
"low": [9],
"close": [10.5],
"volume": [100],
"amount": [1050],
},
}
],
}
provider._post = post
result = provider.daily("stock", "000001.SZ", "2026-07-29")
assert result.rows[0]["time"] == "2026-07-29 15:00:00"
assert result.rows[0]["thscode"] == "000001.SZ"
assert calls == [
("cmd_history_quotation", "expired-token"),
("get_access_token", "refresh-token"),
("cmd_history_quotation", "fresh-token"),
]
def test_trade_context_keeps_real_snapshot_date(tmp_path) -> None:
market = gateway(tmp_path)
context = market.trade_context(
"2026-07-30", datetime(2026, 7, 30, 9, 10, tzinfo=SHANGHAI)
)
assert context.requested_date == "2026-07-30"
assert context.actual_date == "2026-07-29"
assert context.carried_forward is True
assert context.observed_at.isoformat() == "2026-07-29T15:00:00+08:00"
def test_latest_daily_chart_drops_empty_premarket_bar(tmp_path) -> None:
series = gateway(tmp_path).chart(
"stock", "000001.SZ", "day", datetime(2026, 7, 30, 9, 15, tzinfo=SHANGHAI)
)
assert series.trade_date == "2026-07-29"
assert [point.time for point in series.points] == ["2026-07-28", "2026-07-29"]
assert series.points[-1].amount == 13320
def test_minute_chart_contract_has_real_session_bounds_and_hides_source(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
async def scenario(client: httpx.AsyncClient) -> None:
registered = await client.post(
"/api/auth/register",
json={"username": "market-user", "password": "Market-pass-123!"},
)
assert registered.status_code == 201
fake_market = type(
"FakeMarketService",
(),
{
"chart": lambda self, *_: {
"entity_type": "stock",
"identifier": "000001.SZ",
"code": "000001",
"name": "平安银行",
"interval": "minute",
"trade_date": "2026-07-29",
"observed_at": "2026-07-29T15:00:00+08:00",
"previous_close": 10.9,
"range_start": "09:30",
"range_end": "15:00",
"points": [],
}
},
)()
object.__setattr__(application.state.container, "market", fake_market)
response = await client.get("/api/market/entities/stock/000001.SZ/charts/minute")
assert response.status_code == 200
assert response.json()["range_start"] == "09:30"
assert response.json()["range_end"] == "15:00"
assert "source" not in response.text
run_scenario(application, scenario)
def test_search_groups_are_fixed_and_require_authentication(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
async def scenario(client: httpx.AsyncClient) -> None:
assert (await client.get("/api/market/search?q=上证")).status_code == 401
await client.post(
"/api/auth/register",
json={"username": "search-user", "password": "Search-pass-123!"},
)
response = await client.get("/api/market/search?q=上证")
assert response.status_code == 200
groups = response.json()["groups"]
assert [group["label"] for group in groups] == ["股票", "板块", "题材", "指数"]
assert groups[-1]["items"][0]["name"] == "上证指数"
run_scenario(application, scenario)
+6 -2
View File
@@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
database = Database(tmp_path / "app.db")
runner = MigrationRunner(database)
assert runner.upgrade(MIGRATIONS) == (1, 2)
assert runner.upgrade(MIGRATIONS) == (1, 2, 3)
assert {
"users",
"memberships",
@@ -120,8 +120,12 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
"system_credentials",
"llm_models",
"llm_configuration",
"trading_days",
"market_entities",
"market_summaries",
"chart_series",
} <= table_names(database)
assert runner.downgrade(MIGRATIONS, target_version=0) == (2, 1)
assert runner.downgrade(MIGRATIONS, target_version=0) == (3, 2, 1)
assert "users" not in table_names(database)
assert "llm_models" not in table_names(database)