rebuild(stage-5): establish market data gateway and charts
@@ -3,6 +3,10 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
from backend.bootstrap.settings import Settings
|
||||
from backend.data.gateway import DataGateway
|
||||
from backend.data.policy import DataSourcePolicy
|
||||
from backend.data.providers import EastmoneyProvider, IfindProvider, TushareProvider
|
||||
from backend.data.repository import MarketRepository
|
||||
from backend.database.connection import Database
|
||||
from backend.database.repositories.status import DatabaseStatusRepository
|
||||
from backend.features.accounts.credentials import SystemCredentialService
|
||||
@@ -12,6 +16,7 @@ from backend.features.accounts.service import (
|
||||
AccountService,
|
||||
MembershipService,
|
||||
)
|
||||
from backend.features.market import MarketService
|
||||
from backend.security import PasswordHasher, load_or_create_cipher
|
||||
|
||||
|
||||
@@ -24,6 +29,7 @@ class ApplicationContainer:
|
||||
memberships: MembershipService
|
||||
system_credentials: SystemCredentialService
|
||||
model_pool: ModelPoolService
|
||||
market: MarketService
|
||||
|
||||
|
||||
def build_container(settings: Settings) -> ApplicationContainer:
|
||||
@@ -32,12 +38,27 @@ def build_container(settings: Settings) -> ApplicationContainer:
|
||||
credential_repository = SystemCredentialRepository()
|
||||
model_pool_repository = ModelPoolRepository()
|
||||
cipher = load_or_create_cipher(settings)
|
||||
credentials = SystemCredentialService(database, credential_repository, cipher)
|
||||
gateway = DataGateway(
|
||||
database,
|
||||
MarketRepository(),
|
||||
(
|
||||
TushareProvider(lambda: credentials.get("tushare_token")),
|
||||
IfindProvider(
|
||||
lambda: credentials.get("ifind_refresh_token"),
|
||||
lambda: credentials.get("ifind_access_token"),
|
||||
),
|
||||
EastmoneyProvider(),
|
||||
),
|
||||
DataSourcePolicy(),
|
||||
)
|
||||
return ApplicationContainer(
|
||||
settings=settings,
|
||||
database=database,
|
||||
database_status=DatabaseStatusRepository(database),
|
||||
accounts=AccountService(database, account_repository, PasswordHasher(), cipher),
|
||||
memberships=MembershipService(database, account_repository),
|
||||
system_credentials=SystemCredentialService(database, credential_repository, cipher),
|
||||
system_credentials=credentials,
|
||||
model_pool=ModelPoolService(database, model_pool_repository, cipher),
|
||||
market=MarketService(gateway),
|
||||
)
|
||||
|
||||
@@ -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.m0002_model_pool import MIGRATION as MODEL_POOL
|
||||
from backend.database.migrations.m0003_market_foundation import MIGRATION as MARKET_FOUNDATION
|
||||
from backend.database.migrations.runner import Migration
|
||||
|
||||
MIGRATIONS: tuple[Migration, ...] = (ACCOUNTS, MODEL_POOL)
|
||||
MIGRATIONS: tuple[Migration, ...] = (ACCOUNTS, MODEL_POOL, MARKET_FOUNDATION)
|
||||
|
||||
@@ -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 backend.features.accounts.routes import router as accounts_router
|
||||
from backend.features.market.routes import router as market_router
|
||||
from backend.http.routes.health import router as health_router
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health_router)
|
||||
api_router.include_router(accounts_router)
|
||||
api_router.include_router(market_router)
|
||||
|
||||
|
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 WorkspaceView from "./views/WorkspaceView.vue";
|
||||
import EntityDetailView from "./views/EntityDetailView.vue";
|
||||
import { findWorkspace } from "./workspaceRegistry";
|
||||
|
||||
export default createRouter({
|
||||
@@ -15,6 +16,11 @@ export default createRouter({
|
||||
beforeEnter: (to) => (findWorkspace(String(to.params.workspace)) ? true : "/workspace/emotion"),
|
||||
},
|
||||
{ path: "/system", name: "system", component: SystemManagementView },
|
||||
{
|
||||
path: "/market/:entityType/:identifier",
|
||||
name: "entity-detail",
|
||||
component: EntityDetailView,
|
||||
},
|
||||
{ path: "/:pathMatch(.*)*", redirect: "/workspace/emotion" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { onBeforeUnmount, onMounted } from "vue";
|
||||
import DialogHost from "../../shared/components/DialogHost.vue";
|
||||
import ToastHost from "../../shared/components/ToastHost.vue";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import DesktopSidebar from "./DesktopSidebar.vue";
|
||||
import MarketStrip from "./MarketStrip.vue";
|
||||
import MobileNav from "./MobileNav.vue";
|
||||
@@ -11,6 +12,7 @@ import StatusBar from "./StatusBar.vue";
|
||||
import TopBar from "./TopBar.vue";
|
||||
|
||||
const ui = useUiStore();
|
||||
const market = useMarketStore();
|
||||
|
||||
function globalShortcut(event: KeyboardEvent): void {
|
||||
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "k") return;
|
||||
@@ -22,7 +24,10 @@ function globalShortcut(event: KeyboardEvent): void {
|
||||
ui.openDialog("search");
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener("keydown", globalShortcut));
|
||||
onMounted(() => {
|
||||
window.addEventListener("keydown", globalShortcut);
|
||||
void market.load();
|
||||
});
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", globalShortcut));
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,28 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
|
||||
const expanded = ref(false);
|
||||
const cells = ["市场情绪", "涨停", "跌停", "炸板", "封板率", "两市成交", "数据日期"];
|
||||
const market = useMarketStore();
|
||||
const values = computed(() => market.summary?.values ?? {});
|
||||
const cells = computed(() => [
|
||||
["市场情绪", display("temperature")],
|
||||
["涨停", display("limit_up")],
|
||||
["跌停", display("limit_down")],
|
||||
["炸板", display("broken")],
|
||||
["封板率", percent("seal_rate")],
|
||||
["两市成交", amount("amount")],
|
||||
["数据日期", observedTime()],
|
||||
]);
|
||||
|
||||
function display(key: string): string {
|
||||
const value = values.value[key];
|
||||
return typeof value === "number" || typeof value === "string" ? String(value) : "";
|
||||
}
|
||||
|
||||
function percent(key: string): string {
|
||||
const value = values.value[key];
|
||||
return typeof value === "number" ? `${value.toFixed(1)}%` : "";
|
||||
}
|
||||
|
||||
function amount(key: string): string {
|
||||
const value = values.value[key];
|
||||
return typeof value === "number" ? `${(value / 100_000_000).toFixed(2)} 亿` : "";
|
||||
}
|
||||
|
||||
function observedTime(): string {
|
||||
const observed = market.summary?.context.observed_at;
|
||||
if (!observed) return "";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date(observed));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="market-strip" aria-label="市场摘要">
|
||||
<div class="market-strip-row">
|
||||
<span class="market-emotion"><span class="emotion-dot" aria-hidden="true"></span>市场情绪</span>
|
||||
<span>涨停</span>
|
||||
<span>跌停</span>
|
||||
<span>炸板</span>
|
||||
<span>封板率</span>
|
||||
<span>两市成交</span>
|
||||
<span class="faint">等待最新行情</span>
|
||||
<span>涨停 {{ display("limit_up") }}</span>
|
||||
<span>跌停 {{ display("limit_down") }}</span>
|
||||
<span>炸板 {{ display("broken") }}</span>
|
||||
<span>封板率 {{ percent("seal_rate") }}</span>
|
||||
<span>两市成交 {{ amount("amount") }}</span>
|
||||
<span class="faint">{{ market.loading ? "正在读取行情" : observedTime() || market.summary?.context.message || market.error }}</span>
|
||||
<button class="market-toggle" type="button" :aria-expanded="expanded" @click="expanded = !expanded">
|
||||
{{ expanded ? "收起详情" : "展开详情" }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="expanded" class="market-details">
|
||||
<div v-for="cell in cells" :key="cell" class="market-cell">
|
||||
<div class="market-cell-label">{{ cell }}</div>
|
||||
<div class="market-cell-value"></div>
|
||||
<div v-for="cell in cells" :key="cell[0]" class="market-cell">
|
||||
<div class="market-cell-label">{{ cell[0] }}</div>
|
||||
<div class="market-cell-value">{{ cell[1] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -17,6 +17,7 @@ const menuRoot = ref<HTMLElement | null>(null);
|
||||
|
||||
const title = computed(() => {
|
||||
if (route.name === "system") return "系统管理";
|
||||
if (route.name === "entity-detail") return "行情详情";
|
||||
return findWorkspace(String(route.params.workspace ?? "emotion"))?.title ?? "小白复盘";
|
||||
});
|
||||
|
||||
@@ -38,7 +39,7 @@ function changeDate(event: Event): void {
|
||||
!Number.isNaN(parsed.valueOf()) &&
|
||||
parsed.toISOString().slice(0, 10) === value
|
||||
) {
|
||||
market.selectedDate = value;
|
||||
void market.selectDate(value);
|
||||
return;
|
||||
}
|
||||
input.value = market.selectedDate;
|
||||
|
||||
@@ -16,6 +16,9 @@ const statuses = ref<CredentialStatus[]>([]);
|
||||
const values = reactive<Record<string, string>>({});
|
||||
const loading = ref(true);
|
||||
const errorMessage = ref("");
|
||||
const syncing = ref(false);
|
||||
const syncResult = ref("");
|
||||
const syncError = ref("");
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
@@ -45,6 +48,23 @@ async function save(name: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncReference(): Promise<void> {
|
||||
syncing.value = true;
|
||||
syncError.value = "";
|
||||
syncResult.value = "";
|
||||
try {
|
||||
const result = await api.post<{ calendar_days: number; entities: number }>(
|
||||
"/market/reference-sync",
|
||||
);
|
||||
syncResult.value = `已同步 ${result.calendar_days} 个日历日期、${result.entities} 个股票条目`;
|
||||
ui.showToast("基础行情资料已同步");
|
||||
} catch (error) {
|
||||
syncError.value = error instanceof Error ? error.message : "同步失败,请稍后重试。";
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
@@ -65,6 +85,17 @@ onMounted(load);
|
||||
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<header class="card-header"><h2>基础行情资料</h2><span class="faint">交易日历与股票目录</span></header>
|
||||
<div class="card-body disabled-row">
|
||||
<p class="muted">保存有效行情凭据后执行;同步结果用于日期判断和全局搜索。</p>
|
||||
<span v-if="syncResult" class="up">{{ syncResult }}</span>
|
||||
<span v-if="syncError" class="field-error" role="alert">{{ syncError }}</span>
|
||||
<button class="btn" type="button" :disabled="syncing" @click="syncReference">
|
||||
{{ syncing ? "正在同步" : "同步基础资料" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<header class="card-header"><h2>历史数据回补</h2><span class="tag">暂不可用</span></header>
|
||||
<div class="card-body disabled-row">
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import type { MarketEntity } from "../../shared/api/market";
|
||||
import MarketPreviewPanel from "../../shared/market/MarketPreviewPanel.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const entity = computed<MarketEntity>(() => ({
|
||||
entity_type: String(route.params.entityType) as MarketEntity["entity_type"],
|
||||
identifier: String(route.params.identifier),
|
||||
code: String(route.query.code ?? String(route.params.identifier).split(".")[0]),
|
||||
name: String(route.query.name ?? route.params.identifier),
|
||||
sector: route.query.sector ? String(route.query.sector) : null,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page-frame entity-detail-page">
|
||||
<header class="page-header entity-heading">
|
||||
<div><h1>{{ entity.name }}</h1><p class="page-subtitle">{{ entity.code }}<template v-if="entity.sector"> · {{ entity.sector }}</template></p></div>
|
||||
<span class="tag">最新真实行情</span>
|
||||
</header>
|
||||
<MarketPreviewPanel :entity="entity" class="card entity-chart" />
|
||||
</main>
|
||||
</template>
|
||||
@@ -10,6 +10,7 @@ import "./shared/styles/components.css";
|
||||
import "./shared/styles/shell.css";
|
||||
import "./shared/styles/auth.css";
|
||||
import "./shared/styles/account.css";
|
||||
import "./shared/styles/market.css";
|
||||
import "./shared/styles/system.css";
|
||||
import "./shared/styles/mobile.css";
|
||||
|
||||
|
||||
@@ -1,19 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import { marketApi, type MarketEntity, type SearchGroup } from "../api/market";
|
||||
import MarketPreviewPanel from "../market/MarketPreviewPanel.vue";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
const router = useRouter();
|
||||
const ui = useUiStore();
|
||||
const query = ref("");
|
||||
const groups = ref<SearchGroup[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const activeIndex = ref(0);
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let sequence = 0;
|
||||
const flatItems = computed(() => groups.value.flatMap((group) => group.items));
|
||||
const active = computed(() => flatItems.value[activeIndex.value] ?? null);
|
||||
|
||||
watch(query, (value) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
groups.value = [];
|
||||
loading.value = false;
|
||||
error.value = "";
|
||||
return;
|
||||
}
|
||||
debounceTimer = setTimeout(() => void search(normalized), 160);
|
||||
});
|
||||
|
||||
async function search(value: string): Promise<void> {
|
||||
const current = ++sequence;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const response = await marketApi.search(value);
|
||||
if (current === sequence) {
|
||||
groups.value = response.groups;
|
||||
activeIndex.value = 0;
|
||||
}
|
||||
} catch (reason) {
|
||||
if (current === sequence) error.value = reason instanceof Error ? reason.message : "搜索失败";
|
||||
} finally {
|
||||
if (current === sequence) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function select(item: MarketEntity): void {
|
||||
ui.closeDialog();
|
||||
void router.push({
|
||||
name: "entity-detail",
|
||||
params: { entityType: item.entity_type, identifier: item.identifier },
|
||||
query: { code: item.code, name: item.name, sector: item.sector ?? undefined },
|
||||
});
|
||||
}
|
||||
|
||||
function keydown(event: KeyboardEvent): void {
|
||||
if (!flatItems.value.length) return;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
const offset = event.key === "ArrowDown" ? 1 : -1;
|
||||
activeIndex.value = (activeIndex.value + offset + flatItems.value.length) % flatItems.value.length;
|
||||
} else if (event.key === "Enter" && active.value) {
|
||||
event.preventDefault();
|
||||
select(active.value);
|
||||
}
|
||||
}
|
||||
|
||||
function itemIndex(item: MarketEntity): number {
|
||||
return flatItems.value.findIndex((candidate) => candidate.identifier === item.identifier && candidate.entity_type === item.entity_type);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => debounceTimer && clearTimeout(debounceTimer));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-panel">
|
||||
<div class="search-panel" @keydown="keydown">
|
||||
<label class="sr-only" for="global-search">搜索股票、板块、题材或指数</label>
|
||||
<input id="global-search" v-model="query" class="input search-input" autofocus placeholder="搜索股票、板块、题材或指数" />
|
||||
<div class="search-categories" aria-label="搜索结果分类">
|
||||
<span>股票</span><span>板块</span><span>题材</span><span>指数</span>
|
||||
</div>
|
||||
<div class="search-empty">
|
||||
<p>{{ query ? "当前没有匹配结果" : "输入代码或名称开始搜索" }}</p>
|
||||
<span>Ctrl+K</span>
|
||||
<input id="global-search" v-model="query" class="input search-input" autofocus placeholder="搜索股票、板块、题材或指数" autocomplete="off" />
|
||||
<div class="search-layout">
|
||||
<div class="search-results" aria-live="polite">
|
||||
<div v-if="!query" class="search-empty"><p>输入代码或名称开始搜索</p><span>Ctrl+K</span></div>
|
||||
<div v-else-if="loading" class="search-empty"><p>正在搜索</p></div>
|
||||
<div v-else-if="error" class="search-empty"><p>{{ error }}</p></div>
|
||||
<div v-else-if="!flatItems.length" class="search-empty"><p>当前没有匹配结果</p></div>
|
||||
<template v-else>
|
||||
<section v-for="group in groups.filter((item) => item.items.length)" :key="group.entity_type" class="search-group">
|
||||
<h3>{{ group.label }}</h3>
|
||||
<button v-for="item in group.items" :key="item.identifier" type="button" class="search-result" :class="{ active: itemIndex(item) === activeIndex }" @mouseenter="activeIndex = itemIndex(item)" @focus="activeIndex = itemIndex(item)" @click="select(item)">
|
||||
<span class="result-code">{{ item.code }}</span><strong>{{ item.name }}</strong><span>{{ item.sector }}</span>
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
<MarketPreviewPanel class="search-preview" :entity="active" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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 { ref } from "vue";
|
||||
|
||||
import { marketApi, type MarketSummary } from "../api/market";
|
||||
|
||||
function shanghaiDate(): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
@@ -12,12 +14,32 @@ function shanghaiDate(): string {
|
||||
|
||||
export const useMarketStore = defineStore("market", () => {
|
||||
const selectedDate = ref(shanghaiDate());
|
||||
const summary = ref<MarketSummary | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
function moveDate(offset: number): void {
|
||||
const date = new Date(`${selectedDate.value}T12:00:00+08:00`);
|
||||
date.setUTCDate(date.getUTCDate() + offset);
|
||||
selectedDate.value = date.toISOString().slice(0, 10);
|
||||
async function load(date?: string): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
summary.value = await marketApi.summary(date);
|
||||
selectedDate.value = summary.value.context.actual_date ?? summary.value.context.requested_date;
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : "行情摘要读取失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { selectedDate, moveDate };
|
||||
async function selectDate(date: string): Promise<void> {
|
||||
await load(date);
|
||||
}
|
||||
|
||||
async function moveDate(offset: number): Promise<void> {
|
||||
const date = new Date(`${selectedDate.value}T12:00:00+08:00`);
|
||||
date.setUTCDate(date.getUTCDate() + offset);
|
||||
await load(date.toISOString().slice(0, 10));
|
||||
}
|
||||
|
||||
return { selectedDate, summary, loading, error, load, selectDate, moveDate };
|
||||
});
|
||||
|
||||
@@ -103,21 +103,6 @@
|
||||
min-height: var(--s-44);
|
||||
}
|
||||
|
||||
.search-categories {
|
||||
display: flex;
|
||||
gap: var(--s-8);
|
||||
padding-bottom: var(--s-10);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.search-categories span {
|
||||
padding: var(--s-4) var(--s-9);
|
||||
border-radius: var(--tag-radius);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-muted);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.search-empty {
|
||||
min-height: var(--s-200);
|
||||
display: grid;
|
||||
@@ -128,6 +113,67 @@
|
||||
font-size: var(--font-12);
|
||||
}
|
||||
|
||||
.search-layout {
|
||||
min-height: var(--s-320);
|
||||
display: grid;
|
||||
grid-template-columns: var(--s-320) minmax(0, 1fr);
|
||||
gap: var(--s-12);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
max-height: var(--s-400);
|
||||
overflow-y: auto;
|
||||
border-right: var(--s-1) solid var(--color-divider);
|
||||
padding-right: var(--s-12);
|
||||
}
|
||||
|
||||
.search-group + .search-group {
|
||||
margin-top: var(--s-12);
|
||||
}
|
||||
|
||||
.search-group h3 {
|
||||
padding: var(--s-4) var(--s-8);
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--font-11);
|
||||
font-weight: var(--weight-600);
|
||||
}
|
||||
|
||||
.search-result {
|
||||
width: 100%;
|
||||
min-height: var(--s-36);
|
||||
display: grid;
|
||||
grid-template-columns: var(--s-64) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--s-8);
|
||||
padding: var(--s-6) var(--s-8);
|
||||
border-radius: var(--control-radius);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--c-transparent);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.search-result:hover,
|
||||
.search-result.active {
|
||||
color: var(--color-text);
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.search-result strong {
|
||||
overflow: hidden;
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-12-5);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-result span {
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.result-code {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.search-empty span {
|
||||
padding: var(--s-2) var(--s-6);
|
||||
border: var(--s-1) solid var(--color-border);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
.market-preview {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(var(--s-260), 1fr) auto;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
min-height: var(--s-40);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-12);
|
||||
padding: var(--s-8) var(--s-12);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.preview-header > div:first-child {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.preview-header strong {
|
||||
font-size: var(--font-14);
|
||||
}
|
||||
|
||||
.preview-header span,
|
||||
.preview-meta {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.seg-control {
|
||||
display: inline-flex;
|
||||
gap: var(--s-2);
|
||||
margin-left: auto;
|
||||
padding: var(--s-2);
|
||||
border-radius: var(--control-radius);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.seg-control button {
|
||||
min-height: var(--s-24);
|
||||
padding: var(--s-4) var(--s-8);
|
||||
border-radius: var(--radius-5);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--c-transparent);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.seg-control button.active {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.market-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: var(--s-260);
|
||||
padding: var(--s-8);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.candle-up,
|
||||
.candle-down {
|
||||
stroke-width: var(--s-1);
|
||||
}
|
||||
|
||||
.candle-up {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-up);
|
||||
}
|
||||
|
||||
.candle-down {
|
||||
fill: var(--color-down);
|
||||
stroke: var(--color-down);
|
||||
}
|
||||
|
||||
.chart-price,
|
||||
.chart-average {
|
||||
fill: none;
|
||||
stroke-width: var(--s-2);
|
||||
}
|
||||
|
||||
.chart-price {
|
||||
stroke: var(--color-primary);
|
||||
}
|
||||
|
||||
.chart-average {
|
||||
stroke: var(--color-warning);
|
||||
}
|
||||
|
||||
.chart-zero {
|
||||
stroke: var(--color-text-faint);
|
||||
stroke-width: var(--s-1);
|
||||
stroke-dasharray: var(--s-4) var(--s-4);
|
||||
}
|
||||
|
||||
.preview-state {
|
||||
min-height: var(--s-260);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-12);
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
min-height: var(--s-30);
|
||||
padding: var(--s-7) var(--s-12);
|
||||
border-top: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.entity-heading {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.entity-heading h1 {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.entity-chart {
|
||||
min-height: var(--s-400);
|
||||
}
|
||||
@@ -114,6 +114,20 @@
|
||||
grid-template-columns: minmax(0, 1fr) var(--s-64) var(--s-64);
|
||||
}
|
||||
|
||||
.search-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
max-height: none;
|
||||
border-right: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.search-preview {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-view {
|
||||
align-items: start;
|
||||
padding-top: var(--s-34);
|
||||
|
||||
@@ -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")
|
||||
runner = MigrationRunner(database)
|
||||
|
||||
assert runner.upgrade(MIGRATIONS) == (1, 2)
|
||||
assert runner.upgrade(MIGRATIONS) == (1, 2, 3)
|
||||
assert {
|
||||
"users",
|
||||
"memberships",
|
||||
@@ -120,8 +120,12 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
||||
"system_credentials",
|
||||
"llm_models",
|
||||
"llm_configuration",
|
||||
"trading_days",
|
||||
"market_entities",
|
||||
"market_summaries",
|
||||
"chart_series",
|
||||
} <= table_names(database)
|
||||
|
||||
assert runner.downgrade(MIGRATIONS, target_version=0) == (2, 1)
|
||||
assert runner.downgrade(MIGRATIONS, target_version=0) == (3, 2, 1)
|
||||
assert "users" not in table_names(database)
|
||||
assert "llm_models" not in table_names(database)
|
||||
|
||||