rebuild(stage-5): establish market data gateway and charts
@@ -3,6 +3,10 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from backend.bootstrap.settings import Settings
|
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.connection import Database
|
||||||
from backend.database.repositories.status import DatabaseStatusRepository
|
from backend.database.repositories.status import DatabaseStatusRepository
|
||||||
from backend.features.accounts.credentials import SystemCredentialService
|
from backend.features.accounts.credentials import SystemCredentialService
|
||||||
@@ -12,6 +16,7 @@ from backend.features.accounts.service import (
|
|||||||
AccountService,
|
AccountService,
|
||||||
MembershipService,
|
MembershipService,
|
||||||
)
|
)
|
||||||
|
from backend.features.market import MarketService
|
||||||
from backend.security import PasswordHasher, load_or_create_cipher
|
from backend.security import PasswordHasher, load_or_create_cipher
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +29,7 @@ class ApplicationContainer:
|
|||||||
memberships: MembershipService
|
memberships: MembershipService
|
||||||
system_credentials: SystemCredentialService
|
system_credentials: SystemCredentialService
|
||||||
model_pool: ModelPoolService
|
model_pool: ModelPoolService
|
||||||
|
market: MarketService
|
||||||
|
|
||||||
|
|
||||||
def build_container(settings: Settings) -> ApplicationContainer:
|
def build_container(settings: Settings) -> ApplicationContainer:
|
||||||
@@ -32,12 +38,27 @@ def build_container(settings: Settings) -> ApplicationContainer:
|
|||||||
credential_repository = SystemCredentialRepository()
|
credential_repository = SystemCredentialRepository()
|
||||||
model_pool_repository = ModelPoolRepository()
|
model_pool_repository = ModelPoolRepository()
|
||||||
cipher = load_or_create_cipher(settings)
|
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(
|
return ApplicationContainer(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
database=database,
|
database=database,
|
||||||
database_status=DatabaseStatusRepository(database),
|
database_status=DatabaseStatusRepository(database),
|
||||||
accounts=AccountService(database, account_repository, PasswordHasher(), cipher),
|
accounts=AccountService(database, account_repository, PasswordHasher(), cipher),
|
||||||
memberships=MembershipService(database, account_repository),
|
memberships=MembershipService(database, account_repository),
|
||||||
system_credentials=SystemCredentialService(database, credential_repository, cipher),
|
system_credentials=credentials,
|
||||||
model_pool=ModelPoolService(database, model_pool_repository, cipher),
|
model_pool=ModelPoolService(database, model_pool_repository, cipher),
|
||||||
|
market=MarketService(gateway),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from backend.data.gateway import DataGateway
|
||||||
|
|
||||||
|
__all__ = ["DataGateway"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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), ())
|
||||||
@@ -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"]
|
||||||
@@ -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: ...
|
||||||
@@ -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("该标的暂无展示分时数据")
|
||||||
@@ -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
|
||||||
@@ -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:]}"
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from backend.database.migrations.m0001_accounts import MIGRATION as ACCOUNTS
|
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.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
|
from backend.database.migrations.runner import Migration
|
||||||
|
|
||||||
MIGRATIONS: tuple[Migration, ...] = (ACCOUNTS, MODEL_POOL)
|
MIGRATIONS: tuple[Migration, ...] = (ACCOUNTS, MODEL_POOL, MARKET_FOUNDATION)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from backend.features.market.service import MarketService
|
||||||
|
|
||||||
|
__all__ = ["MarketService"]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from backend.features.accounts.routes import router as accounts_router
|
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
|
from backend.http.routes.health import router as health_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(health_router)
|
api_router.include_router(health_router)
|
||||||
api_router.include_router(accounts_router)
|
api_router.include_router(accounts_router)
|
||||||
|
api_router.include_router(market_router)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 131 KiB |
@@ -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类型检查:通过。
|
||||||
|
- Vitest:2个文件、5项通过。
|
||||||
|
- Vite生产构建:通过。
|
||||||
|
- Playwright:3项通过,覆盖既有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的确定性行情任务可以写入正式市场摘要,展示兜底源不能借图表接口进入计算。
|
||||||
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 60 KiB |
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from "vue-router";
|
|||||||
|
|
||||||
import SystemManagementView from "./views/SystemManagementView.vue";
|
import SystemManagementView from "./views/SystemManagementView.vue";
|
||||||
import WorkspaceView from "./views/WorkspaceView.vue";
|
import WorkspaceView from "./views/WorkspaceView.vue";
|
||||||
|
import EntityDetailView from "./views/EntityDetailView.vue";
|
||||||
import { findWorkspace } from "./workspaceRegistry";
|
import { findWorkspace } from "./workspaceRegistry";
|
||||||
|
|
||||||
export default createRouter({
|
export default createRouter({
|
||||||
@@ -15,6 +16,11 @@ export default createRouter({
|
|||||||
beforeEnter: (to) => (findWorkspace(String(to.params.workspace)) ? true : "/workspace/emotion"),
|
beforeEnter: (to) => (findWorkspace(String(to.params.workspace)) ? true : "/workspace/emotion"),
|
||||||
},
|
},
|
||||||
{ path: "/system", name: "system", component: SystemManagementView },
|
{ path: "/system", name: "system", component: SystemManagementView },
|
||||||
|
{
|
||||||
|
path: "/market/:entityType/:identifier",
|
||||||
|
name: "entity-detail",
|
||||||
|
component: EntityDetailView,
|
||||||
|
},
|
||||||
{ path: "/:pathMatch(.*)*", redirect: "/workspace/emotion" },
|
{ path: "/:pathMatch(.*)*", redirect: "/workspace/emotion" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { onBeforeUnmount, onMounted } from "vue";
|
|||||||
import DialogHost from "../../shared/components/DialogHost.vue";
|
import DialogHost from "../../shared/components/DialogHost.vue";
|
||||||
import ToastHost from "../../shared/components/ToastHost.vue";
|
import ToastHost from "../../shared/components/ToastHost.vue";
|
||||||
import { useUiStore } from "../../shared/stores/ui";
|
import { useUiStore } from "../../shared/stores/ui";
|
||||||
|
import { useMarketStore } from "../../shared/stores/market";
|
||||||
import DesktopSidebar from "./DesktopSidebar.vue";
|
import DesktopSidebar from "./DesktopSidebar.vue";
|
||||||
import MarketStrip from "./MarketStrip.vue";
|
import MarketStrip from "./MarketStrip.vue";
|
||||||
import MobileNav from "./MobileNav.vue";
|
import MobileNav from "./MobileNav.vue";
|
||||||
@@ -11,6 +12,7 @@ import StatusBar from "./StatusBar.vue";
|
|||||||
import TopBar from "./TopBar.vue";
|
import TopBar from "./TopBar.vue";
|
||||||
|
|
||||||
const ui = useUiStore();
|
const ui = useUiStore();
|
||||||
|
const market = useMarketStore();
|
||||||
|
|
||||||
function globalShortcut(event: KeyboardEvent): void {
|
function globalShortcut(event: KeyboardEvent): void {
|
||||||
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "k") return;
|
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "k") return;
|
||||||
@@ -22,7 +24,10 @@ function globalShortcut(event: KeyboardEvent): void {
|
|||||||
ui.openDialog("search");
|
ui.openDialog("search");
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => window.addEventListener("keydown", globalShortcut));
|
onMounted(() => {
|
||||||
|
window.addEventListener("keydown", globalShortcut);
|
||||||
|
void market.load();
|
||||||
|
});
|
||||||
onBeforeUnmount(() => window.removeEventListener("keydown", globalShortcut));
|
onBeforeUnmount(() => window.removeEventListener("keydown", globalShortcut));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,68 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
import { useMarketStore } from "../../shared/stores/market";
|
||||||
|
|
||||||
const expanded = ref(false);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="market-strip" aria-label="市场摘要">
|
<section class="market-strip" aria-label="市场摘要">
|
||||||
<div class="market-strip-row">
|
<div class="market-strip-row">
|
||||||
<span class="market-emotion"><span class="emotion-dot" aria-hidden="true"></span>市场情绪</span>
|
<span class="market-emotion"><span class="emotion-dot" aria-hidden="true"></span>市场情绪</span>
|
||||||
<span>涨停</span>
|
<span>涨停 {{ display("limit_up") }}</span>
|
||||||
<span>跌停</span>
|
<span>跌停 {{ display("limit_down") }}</span>
|
||||||
<span>炸板</span>
|
<span>炸板 {{ display("broken") }}</span>
|
||||||
<span>封板率</span>
|
<span>封板率 {{ percent("seal_rate") }}</span>
|
||||||
<span>两市成交</span>
|
<span>两市成交 {{ amount("amount") }}</span>
|
||||||
<span class="faint">等待最新行情</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">
|
<button class="market-toggle" type="button" :aria-expanded="expanded" @click="expanded = !expanded">
|
||||||
{{ expanded ? "收起详情" : "展开详情" }}
|
{{ expanded ? "收起详情" : "展开详情" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="expanded" class="market-details">
|
<div v-if="expanded" class="market-details">
|
||||||
<div v-for="cell in cells" :key="cell" class="market-cell">
|
<div v-for="cell in cells" :key="cell[0]" class="market-cell">
|
||||||
<div class="market-cell-label">{{ cell }}</div>
|
<div class="market-cell-label">{{ cell[0] }}</div>
|
||||||
<div class="market-cell-value"></div>
|
<div class="market-cell-value">{{ cell[1] }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const menuRoot = ref<HTMLElement | null>(null);
|
|||||||
|
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
if (route.name === "system") return "系统管理";
|
if (route.name === "system") return "系统管理";
|
||||||
|
if (route.name === "entity-detail") return "行情详情";
|
||||||
return findWorkspace(String(route.params.workspace ?? "emotion"))?.title ?? "小白复盘";
|
return findWorkspace(String(route.params.workspace ?? "emotion"))?.title ?? "小白复盘";
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ function changeDate(event: Event): void {
|
|||||||
!Number.isNaN(parsed.valueOf()) &&
|
!Number.isNaN(parsed.valueOf()) &&
|
||||||
parsed.toISOString().slice(0, 10) === value
|
parsed.toISOString().slice(0, 10) === value
|
||||||
) {
|
) {
|
||||||
market.selectedDate = value;
|
void market.selectDate(value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
input.value = market.selectedDate;
|
input.value = market.selectedDate;
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ const statuses = ref<CredentialStatus[]>([]);
|
|||||||
const values = reactive<Record<string, string>>({});
|
const values = reactive<Record<string, string>>({});
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
|
const syncing = ref(false);
|
||||||
|
const syncResult = ref("");
|
||||||
|
const syncError = ref("");
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
loading.value = true;
|
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);
|
onMounted(load);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -65,6 +85,17 @@ onMounted(load);
|
|||||||
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
|
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="card">
|
||||||
<header class="card-header"><h2>历史数据回补</h2><span class="tag">暂不可用</span></header>
|
<header class="card-header"><h2>历史数据回补</h2><span class="tag">暂不可用</span></header>
|
||||||
<div class="card-body disabled-row">
|
<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>
|
||||||
@@ -10,6 +10,7 @@ import "./shared/styles/components.css";
|
|||||||
import "./shared/styles/shell.css";
|
import "./shared/styles/shell.css";
|
||||||
import "./shared/styles/auth.css";
|
import "./shared/styles/auth.css";
|
||||||
import "./shared/styles/account.css";
|
import "./shared/styles/account.css";
|
||||||
|
import "./shared/styles/market.css";
|
||||||
import "./shared/styles/system.css";
|
import "./shared/styles/system.css";
|
||||||
import "./shared/styles/mobile.css";
|
import "./shared/styles/mobile.css";
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,100 @@
|
|||||||
<script setup lang="ts">
|
<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 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="search-panel">
|
<div class="search-panel" @keydown="keydown">
|
||||||
<label class="sr-only" for="global-search">搜索股票、板块、题材或指数</label>
|
<label class="sr-only" for="global-search">搜索股票、板块、题材或指数</label>
|
||||||
<input id="global-search" v-model="query" class="input search-input" autofocus placeholder="搜索股票、板块、题材或指数" />
|
<input id="global-search" v-model="query" class="input search-input" autofocus placeholder="搜索股票、板块、题材或指数" autocomplete="off" />
|
||||||
<div class="search-categories" aria-label="搜索结果分类">
|
<div class="search-layout">
|
||||||
<span>股票</span><span>板块</span><span>题材</span><span>指数</span>
|
<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>
|
</div>
|
||||||
<div class="search-empty">
|
<MarketPreviewPanel class="search-preview" :entity="active" />
|
||||||
<p>{{ query ? "当前没有匹配结果" : "输入代码或名称开始搜索" }}</p>
|
|
||||||
<span>Ctrl+K</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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:30–15:00</template>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
import { marketApi, type MarketSummary } from "../api/market";
|
||||||
|
|
||||||
function shanghaiDate(): string {
|
function shanghaiDate(): string {
|
||||||
return new Intl.DateTimeFormat("en-CA", {
|
return new Intl.DateTimeFormat("en-CA", {
|
||||||
timeZone: "Asia/Shanghai",
|
timeZone: "Asia/Shanghai",
|
||||||
@@ -12,12 +14,32 @@ function shanghaiDate(): string {
|
|||||||
|
|
||||||
export const useMarketStore = defineStore("market", () => {
|
export const useMarketStore = defineStore("market", () => {
|
||||||
const selectedDate = ref(shanghaiDate());
|
const selectedDate = ref(shanghaiDate());
|
||||||
|
const summary = ref<MarketSummary | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
function moveDate(offset: number): void {
|
async function load(date?: string): Promise<void> {
|
||||||
const date = new Date(`${selectedDate.value}T12:00:00+08:00`);
|
loading.value = true;
|
||||||
date.setUTCDate(date.getUTCDate() + offset);
|
error.value = "";
|
||||||
selectedDate.value = date.toISOString().slice(0, 10);
|
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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,21 +103,6 @@
|
|||||||
min-height: var(--s-44);
|
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 {
|
.search-empty {
|
||||||
min-height: var(--s-200);
|
min-height: var(--s-200);
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -128,6 +113,67 @@
|
|||||||
font-size: var(--font-12);
|
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 {
|
.search-empty span {
|
||||||
padding: var(--s-2) var(--s-6);
|
padding: var(--s-2) var(--s-6);
|
||||||
border: var(--s-1) solid var(--color-border);
|
border: var(--s-1) solid var(--color-border);
|
||||||
|
|||||||
@@ -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);
|
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 {
|
.auth-view {
|
||||||
align-items: start;
|
align-items: start;
|
||||||
padding-top: var(--s-34);
|
padding-top: var(--s-34);
|
||||||
|
|||||||
@@ -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:30–15: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([]);
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
@@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
|||||||
database = Database(tmp_path / "app.db")
|
database = Database(tmp_path / "app.db")
|
||||||
runner = MigrationRunner(database)
|
runner = MigrationRunner(database)
|
||||||
|
|
||||||
assert runner.upgrade(MIGRATIONS) == (1, 2)
|
assert runner.upgrade(MIGRATIONS) == (1, 2, 3)
|
||||||
assert {
|
assert {
|
||||||
"users",
|
"users",
|
||||||
"memberships",
|
"memberships",
|
||||||
@@ -120,8 +120,12 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
|||||||
"system_credentials",
|
"system_credentials",
|
||||||
"llm_models",
|
"llm_models",
|
||||||
"llm_configuration",
|
"llm_configuration",
|
||||||
|
"trading_days",
|
||||||
|
"market_entities",
|
||||||
|
"market_summaries",
|
||||||
|
"chart_series",
|
||||||
} <= table_names(database)
|
} <= 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 "users" not in table_names(database)
|
||||||
assert "llm_models" not in table_names(database)
|
assert "llm_models" not in table_names(database)
|
||||||
|
|||||||