rebuild(runtime): govern market operations and job truth
This commit is contained in:
@@ -13,7 +13,7 @@ from backend.http.errors import install_error_handlers
|
|||||||
from backend.http.request_context import install_request_context
|
from backend.http.request_context import install_request_context
|
||||||
from backend.http.router import api_router
|
from backend.http.router import api_router
|
||||||
from backend.http.security import install_security_headers
|
from backend.http.security import install_security_headers
|
||||||
from backend.jobs.screener import run_screener_scheduler
|
from backend.jobs.operations import run_operations_scheduler
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ def create_application(settings: Settings | None = None) -> FastAPI:
|
|||||||
stop = asyncio.Event()
|
stop = asyncio.Event()
|
||||||
task = None
|
task = None
|
||||||
if runtime.environment != "test":
|
if runtime.environment != "test":
|
||||||
task = asyncio.create_task(run_screener_scheduler(container.screener, stop))
|
task = asyncio.create_task(run_operations_scheduler(container.operations, stop))
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -19,16 +19,20 @@ from backend.features.accounts.service import (
|
|||||||
from backend.features.heaven.repository import HeavenRepository
|
from backend.features.heaven.repository import HeavenRepository
|
||||||
from backend.features.heaven.service import HeavenService
|
from backend.features.heaven.service import HeavenService
|
||||||
from backend.features.market import MarketService
|
from backend.features.market import MarketService
|
||||||
|
from backend.features.market.events import MarketEventService
|
||||||
from backend.features.market.insights import MarketInsightService
|
from backend.features.market.insights import MarketInsightService
|
||||||
from backend.features.market.sync import MarketSnapshotService
|
from backend.features.market.sync import MarketSnapshotService
|
||||||
from backend.features.mentor.context import MentorContextBuilder
|
from backend.features.mentor.context import MentorContextBuilder
|
||||||
from backend.features.mentor.repository import MentorRepository
|
from backend.features.mentor.repository import MentorRepository
|
||||||
from backend.features.mentor.service import MentorService
|
from backend.features.mentor.service import MentorService
|
||||||
from backend.features.mentor.skills import MentorSkillRegistry
|
from backend.features.mentor.skills import MentorSkillRegistry
|
||||||
|
from backend.features.operations import OperationsService
|
||||||
from backend.features.review import ReviewService
|
from backend.features.review import ReviewService
|
||||||
from backend.features.review.repository import ReviewRepository
|
from backend.features.review.repository import ReviewRepository
|
||||||
from backend.features.screener.repository import ScreenerRepository
|
from backend.features.screener.repository import ScreenerRepository
|
||||||
from backend.features.screener.service import ScreenerService
|
from backend.features.screener.service import ScreenerService
|
||||||
|
from backend.jobs.repository import JobRepository
|
||||||
|
from backend.jobs.service import JobService
|
||||||
from backend.llm import LLMGateway
|
from backend.llm import LLMGateway
|
||||||
from backend.llm.repository import LLMRepository
|
from backend.llm.repository import LLMRepository
|
||||||
from backend.security import PasswordHasher, load_or_create_cipher
|
from backend.security import PasswordHasher, load_or_create_cipher
|
||||||
@@ -49,6 +53,7 @@ class ApplicationContainer:
|
|||||||
mentor: MentorService
|
mentor: MentorService
|
||||||
heaven: HeavenService
|
heaven: HeavenService
|
||||||
review: ReviewService
|
review: ReviewService
|
||||||
|
operations: OperationsService
|
||||||
|
|
||||||
|
|
||||||
def build_container(settings: Settings) -> ApplicationContainer:
|
def build_container(settings: Settings) -> ApplicationContainer:
|
||||||
@@ -104,6 +109,11 @@ def build_container(settings: Settings) -> ApplicationContainer:
|
|||||||
PROJECT_ROOT / "config" / "heaven" / "iching_zh.json",
|
PROJECT_ROOT / "config" / "heaven" / "iching_zh.json",
|
||||||
)
|
)
|
||||||
review = ReviewService(database, ReviewRepository(), gateway, screener, llm)
|
review = ReviewService(database, ReviewRepository(), gateway, screener, llm)
|
||||||
|
jobs = JobService(database, JobRepository())
|
||||||
|
events = MarketEventService(database, market_repository, gateway)
|
||||||
|
operations = OperationsService(
|
||||||
|
database, market_repository, market, events, screener, jobs
|
||||||
|
)
|
||||||
return ApplicationContainer(
|
return ApplicationContainer(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
database=database,
|
database=database,
|
||||||
@@ -118,4 +128,5 @@ def build_container(settings: Settings) -> ApplicationContainer:
|
|||||||
mentor=mentor,
|
mentor=mentor,
|
||||||
heaven=heaven,
|
heaven=heaven,
|
||||||
review=review,
|
review=review,
|
||||||
|
operations=operations,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -115,6 +115,19 @@ class DataGateway:
|
|||||||
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
|
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
|
||||||
return provider.snapshot_inputs(trade_date, previous_trade_date)
|
return provider.snapshot_inputs(trade_date, previous_trade_date)
|
||||||
|
|
||||||
|
def realtime_snapshot_inputs(
|
||||||
|
self, trade_date: str, previous_trade_date: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
provider = self._provider(DataSource.TUSHARE)
|
||||||
|
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
|
||||||
|
directory = self.stock_directory()
|
||||||
|
identifiers = tuple(sorted(directory))
|
||||||
|
if not identifiers:
|
||||||
|
raise MarketDataUnavailable("股票目录为空,无法读取盘中行情")
|
||||||
|
return provider.realtime_market_inputs(
|
||||||
|
trade_date, previous_trade_date, identifiers
|
||||||
|
)
|
||||||
|
|
||||||
def trading_dates(self, through: str, limit: int = 2) -> tuple[str, ...]:
|
def trading_dates(self, through: str, limit: int = 2) -> tuple[str, ...]:
|
||||||
requested = _date(through)
|
requested = _date(through)
|
||||||
with self._database.read() as connection:
|
with self._database.read() as connection:
|
||||||
@@ -163,6 +176,16 @@ class DataGateway:
|
|||||||
raise MarketDataUnavailable("当前动态竞价快照暂不可用")
|
raise MarketDataUnavailable("当前动态竞价快照暂不可用")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def event_reasons(self, trade_date: str) -> ProviderResult:
|
||||||
|
provider = self._provider(DataSource.IFIND)
|
||||||
|
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
|
||||||
|
result = provider.event_reasons(trade_date)
|
||||||
|
if result.metadata.usage != DataUsage.CALCULATION:
|
||||||
|
raise MarketDataUnavailable("事件补充来源未获准参与正式数据治理")
|
||||||
|
if result.metadata.coverage <= 0:
|
||||||
|
raise MarketDataUnavailable("事件原因补充服务暂不可用")
|
||||||
|
return result
|
||||||
|
|
||||||
def sector_members(
|
def sector_members(
|
||||||
self, trade_date: str, sector_name: str, representative: str
|
self, trade_date: str, sector_name: str, representative: str
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ class MarketDataProvider(Protocol):
|
|||||||
self, trade_date: str, previous_trade_date: str
|
self, trade_date: str, previous_trade_date: str
|
||||||
) -> dict[str, ProviderResult | dict[str, Any]]: ...
|
) -> dict[str, ProviderResult | dict[str, Any]]: ...
|
||||||
|
|
||||||
|
def realtime_market_inputs(
|
||||||
|
self,
|
||||||
|
trade_date: str,
|
||||||
|
previous_trade_date: str,
|
||||||
|
identifiers: tuple[str, ...],
|
||||||
|
) -> dict[str, ProviderResult | dict[str, Any]]: ...
|
||||||
|
|
||||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult: ...
|
def sector_members(self, representative: str, trade_date: str) -> ProviderResult: ...
|
||||||
|
|
||||||
def heaven_inputs(
|
def heaven_inputs(
|
||||||
@@ -49,4 +56,6 @@ class MarketDataProvider(Protocol):
|
|||||||
self, identifiers: tuple[str, ...], start_time: str, end_time: str
|
self, identifiers: tuple[str, ...], start_time: str, end_time: str
|
||||||
) -> ProviderResult: ...
|
) -> ProviderResult: ...
|
||||||
|
|
||||||
|
def event_reasons(self, trade_date: str) -> ProviderResult: ...
|
||||||
|
|
||||||
def screener_inputs(self, trade_dates: tuple[str, ...]) -> dict[str, ProviderResult | None]: ...
|
def screener_inputs(self, trade_dates: tuple[str, ...]) -> dict[str, ProviderResult | None]: ...
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ class EastmoneyProvider:
|
|||||||
) -> dict[str, ProviderResult | dict[str, object]]:
|
) -> dict[str, ProviderResult | dict[str, object]]:
|
||||||
raise ProviderError("The display provider cannot build market snapshots")
|
raise ProviderError("The display provider cannot build market snapshots")
|
||||||
|
|
||||||
|
def realtime_market_inputs(
|
||||||
|
self,
|
||||||
|
trade_date: str,
|
||||||
|
previous_trade_date: str,
|
||||||
|
identifiers: tuple[str, ...],
|
||||||
|
) -> dict[str, ProviderResult | dict[str, object]]:
|
||||||
|
raise ProviderError("The display provider cannot build realtime market snapshots")
|
||||||
|
|
||||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
||||||
raise ProviderError("The display provider is not the constituent authority")
|
raise ProviderError("The display provider is not the constituent authority")
|
||||||
|
|
||||||
@@ -126,6 +134,9 @@ class EastmoneyProvider:
|
|||||||
) -> ProviderResult:
|
) -> ProviderResult:
|
||||||
raise ProviderError("The display provider cannot supply calculation snapshots")
|
raise ProviderError("The display provider cannot supply calculation snapshots")
|
||||||
|
|
||||||
|
def event_reasons(self, trade_date: str) -> ProviderResult:
|
||||||
|
raise ProviderError("The display provider cannot supply event reasons")
|
||||||
|
|
||||||
def screener_inputs(self, trade_dates: tuple[str, ...]) -> dict[str, ProviderResult | None]:
|
def screener_inputs(self, trade_dates: tuple[str, ...]) -> dict[str, ProviderResult | None]:
|
||||||
raise ProviderError("The display provider cannot supply screener factors")
|
raise ProviderError("The display provider cannot supply screener factors")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -89,6 +90,14 @@ class IfindProvider:
|
|||||||
) -> dict[str, ProviderResult | dict[str, Any]]:
|
) -> dict[str, ProviderResult | dict[str, Any]]:
|
||||||
raise ProviderError("iFinD is not the post-close snapshot authority")
|
raise ProviderError("iFinD is not the post-close snapshot authority")
|
||||||
|
|
||||||
|
def realtime_market_inputs(
|
||||||
|
self,
|
||||||
|
trade_date: str,
|
||||||
|
previous_trade_date: str,
|
||||||
|
identifiers: tuple[str, ...],
|
||||||
|
) -> dict[str, ProviderResult | dict[str, Any]]:
|
||||||
|
raise ProviderError("iFinD不承担全市场盘中快照计算")
|
||||||
|
|
||||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
||||||
raise ProviderError("iFinD is not the Shenwan constituent authority")
|
raise ProviderError("iFinD is not the Shenwan constituent authority")
|
||||||
|
|
||||||
@@ -145,6 +154,76 @@ class IfindProvider:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def event_reasons(self, trade_date: str) -> ProviderResult:
|
||||||
|
date = datetime.fromisoformat(_display(trade_date)).date()
|
||||||
|
display = f"{date.year}年{date.month}月{date.day}日"
|
||||||
|
queries = {
|
||||||
|
"limit_up": (
|
||||||
|
f"{display}涨停股票,股票代码、股票简称、涨停原因、"
|
||||||
|
"首次涨停时间、最终涨停时间、开板次数"
|
||||||
|
),
|
||||||
|
"broken": (
|
||||||
|
f"{display}曾涨停但收盘未涨停的股票,股票代码、股票简称、"
|
||||||
|
"涨停原因、首次涨停时间、开板次数"
|
||||||
|
),
|
||||||
|
"limit_down": f"{display}跌停股票,股票代码、股票简称、跌停原因",
|
||||||
|
}
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
completed = 0
|
||||||
|
for event_type, query in queries.items():
|
||||||
|
try:
|
||||||
|
payload = self._request(
|
||||||
|
"smart_stock_picking",
|
||||||
|
{"searchstring": query, "searchtype": "stock"},
|
||||||
|
)
|
||||||
|
except ProviderError:
|
||||||
|
continue
|
||||||
|
completed += 1
|
||||||
|
for raw in _result(
|
||||||
|
payload, "event", "not_applicable", SnapshotState.FINAL
|
||||||
|
).rows:
|
||||||
|
identifier = _event_identifier(raw)
|
||||||
|
if not identifier:
|
||||||
|
continue
|
||||||
|
reason_tokens = (
|
||||||
|
("跌停原因", "风险线索", "原因")
|
||||||
|
if event_type == "limit_down"
|
||||||
|
else ("涨停原因类别", "涨停原因", "触板逻辑", "原因")
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"event_type": event_type,
|
||||||
|
"identifier": identifier,
|
||||||
|
"reason": str(_event_field(raw, reason_tokens) or "").strip(),
|
||||||
|
"first_time": _event_time(
|
||||||
|
_event_field(
|
||||||
|
raw, ("首次涨停时间", "首次触板时间", "首次封板时间")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"last_time": _event_time(
|
||||||
|
_event_field(
|
||||||
|
raw, ("最终涨停时间", "最后涨停时间", "最后封板时间")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"open_times": _event_integer(
|
||||||
|
_event_field(raw, ("开板次数", "打开涨停次数"))
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return ProviderResult(
|
||||||
|
tuple(rows),
|
||||||
|
ObservationMetadata(
|
||||||
|
source=self.source,
|
||||||
|
observed_at=datetime.now(SHANGHAI),
|
||||||
|
unit="event",
|
||||||
|
adjustment="not_applicable",
|
||||||
|
freshness_seconds=0,
|
||||||
|
coverage=completed / len(queries),
|
||||||
|
state=SnapshotState.FINAL,
|
||||||
|
usage=DataUsage.CALCULATION,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def screener_inputs(self, trade_dates: tuple[str, ...]) -> dict[str, ProviderResult | None]:
|
def screener_inputs(self, trade_dates: tuple[str, ...]) -> dict[str, ProviderResult | None]:
|
||||||
raise ProviderError("iFinD尚未批准用于盘后因子批量计算")
|
raise ProviderError("iFinD尚未批准用于盘后因子批量计算")
|
||||||
|
|
||||||
@@ -295,3 +374,40 @@ def _error_code(payload: dict[str, Any]) -> int:
|
|||||||
return int(payload.get("errorcode", payload.get("code", 0)) or 0)
|
return int(payload.get("errorcode", payload.get("code", 0)) or 0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def _event_field(row: dict[str, Any], tokens: tuple[str, ...]) -> Any:
|
||||||
|
for token in tokens:
|
||||||
|
for key, value in row.items():
|
||||||
|
if token in str(key) and value not in (None, ""):
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _event_identifier(row: dict[str, Any]) -> str:
|
||||||
|
value = _event_field(row, ("股票代码", "证券代码", "代码", "thscode"))
|
||||||
|
text = str(value or "").strip().upper()
|
||||||
|
match = re.search(r"(?<!\d)(\d{6})(?!\d)", text)
|
||||||
|
if not match:
|
||||||
|
return ""
|
||||||
|
code = match.group(1)
|
||||||
|
if re.fullmatch(r"\d{6}\.(?:SH|SZ|BJ)", text):
|
||||||
|
return text
|
||||||
|
suffix = "BJ" if code.startswith(("4", "8", "9")) else "SH" if code.startswith("6") else "SZ"
|
||||||
|
return f"{code}.{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _event_time(value: Any) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
match = re.search(r"(?<!\d)(\d{1,2}):(\d{2})(?::\d{2})?(?!\d)", text)
|
||||||
|
if match:
|
||||||
|
return f"{int(match.group(1)):02d}:{match.group(2)}"
|
||||||
|
compact = re.search(r"(?<!\d)(\d{2})(\d{2})(\d{2})(?!\d)", text)
|
||||||
|
return f"{compact.group(1)}:{compact.group(2)}" if compact else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _event_integer(value: Any) -> int | None:
|
||||||
|
try:
|
||||||
|
return max(0, int(float(value))) if value not in (None, "") else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|||||||
@@ -130,6 +130,59 @@ class TushareProvider:
|
|||||||
)
|
)
|
||||||
return datasets
|
return datasets
|
||||||
|
|
||||||
|
def realtime_market_inputs(
|
||||||
|
self,
|
||||||
|
trade_date: str,
|
||||||
|
previous_trade_date: str,
|
||||||
|
identifiers: tuple[str, ...],
|
||||||
|
) -> dict[str, ProviderResult | dict[str, Any]]:
|
||||||
|
current = _compact(trade_date)
|
||||||
|
previous = _compact(previous_trade_date)
|
||||||
|
quotes = self._query(
|
||||||
|
"rt_k",
|
||||||
|
{"ts_code": ",".join(identifiers)},
|
||||||
|
"",
|
||||||
|
unit="mixed",
|
||||||
|
)
|
||||||
|
normalized = tuple(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"trade_date": current,
|
||||||
|
"pct_chg": _change(row.get("close"), row.get("pre_close")),
|
||||||
|
"amount_unit": "yuan",
|
||||||
|
}
|
||||||
|
for row in quotes.rows
|
||||||
|
if _number(row.get("close")) > 0 and _number(row.get("pre_close")) > 0
|
||||||
|
)
|
||||||
|
daily = ProviderResult(
|
||||||
|
normalized,
|
||||||
|
replace(
|
||||||
|
quotes.metadata,
|
||||||
|
coverage=min(len(normalized) / max(len(identifiers), 1), 1),
|
||||||
|
state=SnapshotState.REALTIME,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"daily": daily,
|
||||||
|
"price_limits": self._query(
|
||||||
|
"stk_limit",
|
||||||
|
{"trade_date": current},
|
||||||
|
"ts_code,trade_date,up_limit,down_limit",
|
||||||
|
unit="yuan/share",
|
||||||
|
),
|
||||||
|
"previous_limit_up": self._query(
|
||||||
|
"limit_list_d",
|
||||||
|
{"trade_date": previous, "limit_type": "U"},
|
||||||
|
(
|
||||||
|
"trade_date,ts_code,industry,name,close,pct_chg,amount,limit_amount,"
|
||||||
|
"float_mv,total_mv,turnover_ratio,fd_amount,first_time,last_time,"
|
||||||
|
"open_times,up_stat,limit_times"
|
||||||
|
),
|
||||||
|
unit="mixed",
|
||||||
|
empty_is_complete=True,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
||||||
target = _compact(trade_date)
|
target = _compact(trade_date)
|
||||||
industry, members = self._sector_memberships(representative, target)
|
industry, members = self._sector_memberships(representative, target)
|
||||||
@@ -354,6 +407,9 @@ class TushareProvider:
|
|||||||
) -> ProviderResult:
|
) -> ProviderResult:
|
||||||
raise ProviderError("Tushare不提供动态竞价快照")
|
raise ProviderError("Tushare不提供动态竞价快照")
|
||||||
|
|
||||||
|
def event_reasons(self, trade_date: str) -> ProviderResult:
|
||||||
|
raise ProviderError("Tushare涨跌停榜单不提供可用的事件原因字段")
|
||||||
|
|
||||||
def screener_inputs(self, trade_dates: tuple[str, ...]) -> dict[str, ProviderResult | None]:
|
def screener_inputs(self, trade_dates: tuple[str, ...]) -> dict[str, ProviderResult | None]:
|
||||||
if len(trade_dates) < 21:
|
if len(trade_dates) < 21:
|
||||||
raise ProviderError("选股因子至少需要21个交易日")
|
raise ProviderError("选股因子至少需要21个交易日")
|
||||||
@@ -628,6 +684,12 @@ def _display(value: str) -> str:
|
|||||||
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
|
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _change(close: Any, previous: Any) -> float:
|
||||||
|
current = _number(close)
|
||||||
|
prior = _number(previous)
|
||||||
|
return round((current / prior - 1) * 100, 4) if current > 0 and prior > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
def _quarter_periods(through: str, count: int) -> tuple[str, ...]:
|
def _quarter_periods(through: str, count: int) -> tuple[str, ...]:
|
||||||
year = int(through[:4])
|
year = int(through[:4])
|
||||||
quarter = (int(through[4:6]) - 1) // 3
|
quarter = (int(through[4:6]) - 1) // 3
|
||||||
|
|||||||
@@ -163,6 +163,21 @@ class MarketRepository:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def open_dates_between(
|
||||||
|
self, connection: sqlite3.Connection, start_date: str, end_date: str
|
||||||
|
) -> 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 BETWEEN ? AND ?
|
||||||
|
ORDER BY trade_date
|
||||||
|
""",
|
||||||
|
(start_date, end_date),
|
||||||
|
).fetchall()
|
||||||
|
)
|
||||||
|
|
||||||
def active_stock_count(self, connection: sqlite3.Connection) -> int:
|
def active_stock_count(self, connection: sqlite3.Connection) -> int:
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"""
|
"""
|
||||||
@@ -235,6 +250,81 @@ class MarketRepository:
|
|||||||
(through,),
|
(through,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
def save_event_revision(
|
||||||
|
self,
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
trade_date: str,
|
||||||
|
identifier: str,
|
||||||
|
event_type: str,
|
||||||
|
reason: str,
|
||||||
|
first_time: str,
|
||||||
|
last_time: str,
|
||||||
|
open_times: int | None,
|
||||||
|
source: str,
|
||||||
|
priority: int,
|
||||||
|
created_by: int | None,
|
||||||
|
created_at: str,
|
||||||
|
) -> int:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO market_event_revisions (
|
||||||
|
trade_date, identifier, event_type, reason, first_time, last_time,
|
||||||
|
open_times, source, priority, created_by, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
trade_date,
|
||||||
|
identifier,
|
||||||
|
event_type,
|
||||||
|
reason,
|
||||||
|
first_time,
|
||||||
|
last_time,
|
||||||
|
open_times,
|
||||||
|
source,
|
||||||
|
priority,
|
||||||
|
created_by,
|
||||||
|
created_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return int(cursor.lastrowid)
|
||||||
|
|
||||||
|
def event_revisions(
|
||||||
|
self, connection: sqlite3.Connection, trade_date: str
|
||||||
|
) -> tuple[sqlite3.Row, ...]:
|
||||||
|
return tuple(
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT revisions.*,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY identifier, event_type
|
||||||
|
ORDER BY priority DESC, id DESC
|
||||||
|
) AS rank
|
||||||
|
FROM market_event_revisions AS revisions
|
||||||
|
WHERE trade_date = ?
|
||||||
|
) WHERE rank = 1
|
||||||
|
""",
|
||||||
|
(trade_date,),
|
||||||
|
).fetchall()
|
||||||
|
)
|
||||||
|
|
||||||
|
def event_revision_history(
|
||||||
|
self, connection: sqlite3.Connection, trade_date: str, identifier: str
|
||||||
|
) -> tuple[sqlite3.Row, ...]:
|
||||||
|
return tuple(
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT revisions.*, users.username AS created_by_name
|
||||||
|
FROM market_event_revisions AS revisions
|
||||||
|
LEFT JOIN users ON users.id = revisions.created_by
|
||||||
|
WHERE revisions.trade_date = ? AND revisions.identifier = ?
|
||||||
|
ORDER BY revisions.id DESC
|
||||||
|
""",
|
||||||
|
(trade_date, identifier),
|
||||||
|
).fetchall()
|
||||||
|
)
|
||||||
|
|
||||||
def sector_members(
|
def sector_members(
|
||||||
self, connection: sqlite3.Connection, trade_date: str, sector_name: str
|
self, connection: sqlite3.Connection, trade_date: str, sector_name: str
|
||||||
) -> sqlite3.Row | None:
|
) -> sqlite3.Row | None:
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from backend.database.migrations.runner import Migration
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade(connection: sqlite3.Connection) -> None:
|
||||||
|
statements = (
|
||||||
|
"""
|
||||||
|
CREATE TABLE job_runs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
run_key TEXT NOT NULL,
|
||||||
|
requested_date TEXT NOT NULL DEFAULT '',
|
||||||
|
trigger TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('running','completed','failed')),
|
||||||
|
attempt INTEGER NOT NULL CHECK (attempt >= 1),
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
finished_at TEXT,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
coverage REAL CHECK (coverage BETWEEN 0 AND 1),
|
||||||
|
source_set_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
output_version TEXT NOT NULL DEFAULT '',
|
||||||
|
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
error_code TEXT NOT NULL DEFAULT '',
|
||||||
|
error_message TEXT NOT NULL DEFAULT '',
|
||||||
|
UNIQUE(kind, run_key, attempt)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX job_runs_active_kind_idx
|
||||||
|
ON job_runs(kind) WHERE status = 'running'
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX job_runs_kind_started_idx
|
||||||
|
ON job_runs(kind, started_at DESC, id DESC)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE market_event_revisions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
trade_date TEXT NOT NULL,
|
||||||
|
identifier TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL CHECK (event_type IN ('limit_up','broken','limit_down')),
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
first_time TEXT NOT NULL DEFAULT '',
|
||||||
|
last_time TEXT NOT NULL DEFAULT '',
|
||||||
|
open_times INTEGER CHECK (open_times IS NULL OR open_times >= 0),
|
||||||
|
source TEXT NOT NULL CHECK (source IN ('tushare','ifind','admin')),
|
||||||
|
priority INTEGER NOT NULL CHECK (priority >= 0),
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX market_event_revisions_lookup_idx
|
||||||
|
ON market_event_revisions(trade_date, identifier, event_type, priority DESC, id DESC)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
for statement in statements:
|
||||||
|
connection.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade(connection: sqlite3.Connection) -> None:
|
||||||
|
connection.execute("DROP TABLE market_event_revisions")
|
||||||
|
connection.execute("DROP TABLE job_runs")
|
||||||
|
|
||||||
|
|
||||||
|
MIGRATION = Migration(
|
||||||
|
version=11,
|
||||||
|
name="create_operations_audit",
|
||||||
|
signature="operations:v1:jobs-event-revisions",
|
||||||
|
upgrade=upgrade,
|
||||||
|
downgrade=downgrade,
|
||||||
|
)
|
||||||
@@ -8,6 +8,7 @@ from backend.database.migrations.m0007_screener import MIGRATION as SCREENER
|
|||||||
from backend.database.migrations.m0008_mentor_llm import MIGRATION as MENTOR_LLM
|
from backend.database.migrations.m0008_mentor_llm import MIGRATION as MENTOR_LLM
|
||||||
from backend.database.migrations.m0009_heaven import MIGRATION as HEAVEN
|
from backend.database.migrations.m0009_heaven import MIGRATION as HEAVEN
|
||||||
from backend.database.migrations.m0010_review import MIGRATION as REVIEW
|
from backend.database.migrations.m0010_review import MIGRATION as REVIEW
|
||||||
|
from backend.database.migrations.m0011_operations import MIGRATION as OPERATIONS
|
||||||
from backend.database.migrations.runner import Migration
|
from backend.database.migrations.runner import Migration
|
||||||
|
|
||||||
MIGRATIONS: tuple[Migration, ...] = (
|
MIGRATIONS: tuple[Migration, ...] = (
|
||||||
@@ -21,4 +22,5 @@ MIGRATIONS: tuple[Migration, ...] = (
|
|||||||
MENTOR_LLM,
|
MENTOR_LLM,
|
||||||
HEAVEN,
|
HEAVEN,
|
||||||
REVIEW,
|
REVIEW,
|
||||||
|
OPERATIONS,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from backend.data.gateway import DataGateway
|
||||||
|
from backend.data.repository import MarketRepository
|
||||||
|
from backend.database.connection import Database
|
||||||
|
|
||||||
|
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||||
|
EVENT_KEYS = {"limit_up": "limits", "broken": "broken", "limit_down": "down_limits"}
|
||||||
|
|
||||||
|
|
||||||
|
class MarketEventError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MarketEventService:
|
||||||
|
def __init__(
|
||||||
|
self, database: Database, repository: MarketRepository, gateway: DataGateway
|
||||||
|
) -> None:
|
||||||
|
self._database = database
|
||||||
|
self._repository = repository
|
||||||
|
self._gateway = gateway
|
||||||
|
|
||||||
|
def supplement(self, trade_date: str) -> dict[str, Any]:
|
||||||
|
target = _date(trade_date)
|
||||||
|
snapshot = self._snapshot(target)
|
||||||
|
expected = {
|
||||||
|
(str(row.get("identifier") or ""), event_type)
|
||||||
|
for event_type, key in EVENT_KEYS.items()
|
||||||
|
for row in snapshot.get(key) or []
|
||||||
|
if row.get("identifier")
|
||||||
|
}
|
||||||
|
if not expected:
|
||||||
|
return {
|
||||||
|
"trade_date": target,
|
||||||
|
"events": 0,
|
||||||
|
"updated": 0,
|
||||||
|
"coverage": 1.0,
|
||||||
|
"source_set": ["local"],
|
||||||
|
"output_version": "event-revisions-v1",
|
||||||
|
}
|
||||||
|
result = self._gateway.event_reasons(target)
|
||||||
|
now = datetime.now(SHANGHAI).isoformat(timespec="seconds")
|
||||||
|
updated = 0
|
||||||
|
matched: set[tuple[str, str]] = set()
|
||||||
|
with self._database.transaction() as connection:
|
||||||
|
current = {
|
||||||
|
(str(row["identifier"]), str(row["event_type"])): dict(row)
|
||||||
|
for row in self._repository.event_revisions(connection, target)
|
||||||
|
}
|
||||||
|
for row in result.rows:
|
||||||
|
identity = (str(row.get("identifier") or ""), str(row.get("event_type") or ""))
|
||||||
|
if identity not in expected:
|
||||||
|
continue
|
||||||
|
useful = any(
|
||||||
|
row.get(field) not in (None, "")
|
||||||
|
for field in ("reason", "first_time", "last_time", "open_times")
|
||||||
|
)
|
||||||
|
if not useful:
|
||||||
|
continue
|
||||||
|
matched.add(identity)
|
||||||
|
values = _revision_values(row)
|
||||||
|
existing = current.get(identity)
|
||||||
|
if existing and int(existing["priority"]) >= 100:
|
||||||
|
continue
|
||||||
|
if existing and all(existing.get(key) == value for key, value in values.items()):
|
||||||
|
continue
|
||||||
|
self._repository.save_event_revision(
|
||||||
|
connection,
|
||||||
|
trade_date=target,
|
||||||
|
identifier=identity[0],
|
||||||
|
event_type=identity[1],
|
||||||
|
source="ifind",
|
||||||
|
priority=20,
|
||||||
|
created_by=None,
|
||||||
|
created_at=now,
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
return {
|
||||||
|
"trade_date": target,
|
||||||
|
"events": len(expected),
|
||||||
|
"matched": len(matched),
|
||||||
|
"updated": updated,
|
||||||
|
"coverage": round(len(matched) / len(expected), 4),
|
||||||
|
"source_set": ["ifind", "local"],
|
||||||
|
"output_version": "event-revisions-v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
def revise(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
trade_date: str,
|
||||||
|
identifier: str,
|
||||||
|
event_type: str,
|
||||||
|
reason: str,
|
||||||
|
first_time: str,
|
||||||
|
last_time: str,
|
||||||
|
open_times: int | None,
|
||||||
|
user_id: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
target = _date(trade_date)
|
||||||
|
normalized = identifier.strip().upper()
|
||||||
|
if event_type not in EVENT_KEYS:
|
||||||
|
raise MarketEventError("事件类型无效")
|
||||||
|
snapshot = self._snapshot(target)
|
||||||
|
exists = any(
|
||||||
|
str(row.get("identifier") or "") == normalized
|
||||||
|
for row in snapshot.get(EVENT_KEYS[event_type]) or []
|
||||||
|
)
|
||||||
|
if not exists:
|
||||||
|
raise MarketEventError("该股票不在所选日期的对应事件池中")
|
||||||
|
normalized_reason = " ".join(reason.split())
|
||||||
|
if not 1 <= len(normalized_reason) <= 200:
|
||||||
|
raise MarketEventError("原因应为1至200个字符")
|
||||||
|
values = {
|
||||||
|
"reason": normalized_reason,
|
||||||
|
"first_time": _time(first_time),
|
||||||
|
"last_time": _time(last_time),
|
||||||
|
"open_times": open_times,
|
||||||
|
}
|
||||||
|
with self._database.transaction() as connection:
|
||||||
|
revision_id = self._repository.save_event_revision(
|
||||||
|
connection,
|
||||||
|
trade_date=target,
|
||||||
|
identifier=normalized,
|
||||||
|
event_type=event_type,
|
||||||
|
source="admin",
|
||||||
|
priority=100,
|
||||||
|
created_by=user_id,
|
||||||
|
created_at=datetime.now(SHANGHAI).isoformat(timespec="seconds"),
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
return {"id": revision_id, "trade_date": target, "identifier": normalized, **values}
|
||||||
|
|
||||||
|
def history(self, trade_date: str, identifier: str) -> list[dict[str, Any]]:
|
||||||
|
target = _date(trade_date)
|
||||||
|
with self._database.read() as connection:
|
||||||
|
rows = self._repository.event_revision_history(
|
||||||
|
connection, target, identifier.strip().upper()
|
||||||
|
)
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def _snapshot(self, trade_date: str) -> dict[str, Any]:
|
||||||
|
with self._database.read() as connection:
|
||||||
|
row = self._repository.latest_summary(connection, trade_date)
|
||||||
|
if row is None or str(row["trade_date"]) != trade_date:
|
||||||
|
raise MarketEventError("所选日期没有正式行情快照")
|
||||||
|
return json.loads(str(row["payload_json"]))
|
||||||
|
|
||||||
|
|
||||||
|
def apply_event_revisions(
|
||||||
|
payload: dict[str, Any], revisions: tuple[Any, ...]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
index = {
|
||||||
|
(str(row["identifier"]), str(row["event_type"])): row for row in revisions
|
||||||
|
}
|
||||||
|
for event_type, key in EVENT_KEYS.items():
|
||||||
|
for item in payload.get(key) or []:
|
||||||
|
revision = index.get((str(item.get("identifier") or ""), event_type))
|
||||||
|
if revision is None:
|
||||||
|
continue
|
||||||
|
for field in ("reason", "first_time", "last_time", "open_times"):
|
||||||
|
value = revision[field]
|
||||||
|
if value not in (None, ""):
|
||||||
|
item[field] = value
|
||||||
|
item["reason_source"] = str(revision["source"])
|
||||||
|
item["revision_id"] = int(revision["id"])
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _revision_values(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"reason": str(row.get("reason") or "").strip(),
|
||||||
|
"first_time": _time(str(row.get("first_time") or "")),
|
||||||
|
"last_time": _time(str(row.get("last_time") or "")),
|
||||||
|
"open_times": row.get("open_times") if isinstance(row.get("open_times"), int) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _date(value: str) -> str:
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value).isoformat()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise MarketEventError("日期格式无效") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _time(value: str) -> str:
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return datetime.strptime(normalized, "%H:%M").strftime("%H:%M")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise MarketEventError("事件时间格式应为HH:MM") from exc
|
||||||
@@ -86,7 +86,7 @@ def sync_snapshot(
|
|||||||
_principal: AdminWritePrincipal,
|
_principal: AdminWritePrincipal,
|
||||||
requested_date: Annotated[str | None, Query(alias="date")] = None,
|
requested_date: Annotated[str | None, Query(alias="date")] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return request.app.state.container.market.sync_snapshot(requested_date)
|
return request.app.state.container.operations.refresh(requested_date)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/workspaces/{key}", response_model=MarketWorkspaceResponse)
|
@router.get("/workspaces/{key}", response_model=MarketWorkspaceResponse)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from backend.data.gateway import DataGateway, MarketDataUnavailable
|
from backend.data.gateway import DataGateway, MarketDataUnavailable
|
||||||
@@ -96,6 +97,11 @@ class MarketService:
|
|||||||
def sync_snapshot(self, requested_date: str | None = None) -> dict[str, Any]:
|
def sync_snapshot(self, requested_date: str | None = None) -> dict[str, Any]:
|
||||||
return self._call(self._snapshots.sync, requested_date)
|
return self._call(self._snapshots.sync, requested_date)
|
||||||
|
|
||||||
|
def sync_realtime(
|
||||||
|
self, requested_date: str | None = None, now: datetime | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return self._call(self._snapshots.sync_realtime, requested_date, now)
|
||||||
|
|
||||||
def workspace(self, key: str, requested_date: str | None = None) -> dict[str, Any]:
|
def workspace(self, key: str, requested_date: str | None = None) -> dict[str, Any]:
|
||||||
return self._call(self._snapshots.workspace, key, requested_date)
|
return self._call(self._snapshots.workspace, key, requested_date)
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ def build_snapshot(
|
|||||||
up_count = sum(_number(row.get("pct_chg")) > 0 for row in daily_rows)
|
up_count = sum(_number(row.get("pct_chg")) > 0 for row in daily_rows)
|
||||||
down_count = sum(_number(row.get("pct_chg")) < 0 for row in daily_rows)
|
down_count = sum(_number(row.get("pct_chg")) < 0 for row in daily_rows)
|
||||||
flat_count = len(daily_rows) - up_count - down_count
|
flat_count = len(daily_rows) - up_count - down_count
|
||||||
amount = sum(_number(row.get("amount")) * 1000 for row in daily_rows)
|
amount = sum(_amount_yuan(row) for row in daily_rows)
|
||||||
seal_rate = len(limits) / max(len(limits) + len(broken), 1) * 100
|
seal_rate = len(limits) / max(len(limits) + len(broken), 1) * 100
|
||||||
sectors = _sectors(limits)
|
sectors = _sectors(limits)
|
||||||
previous_sectors = _sectors(previous_limits)
|
previous_sectors = _sectors(previous_limits)
|
||||||
@@ -63,6 +63,66 @@ def build_snapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_realtime_inputs(
|
||||||
|
inputs: dict[str, ProviderResult | dict[str, Any]],
|
||||||
|
directory: dict[str, dict[str, Any]],
|
||||||
|
) -> dict[str, ProviderResult | dict[str, Any]]:
|
||||||
|
daily_result = inputs.get("daily")
|
||||||
|
limits_result = inputs.get("price_limits")
|
||||||
|
previous_result = inputs.get("previous_limit_up")
|
||||||
|
if not isinstance(daily_result, ProviderResult) or not isinstance(
|
||||||
|
limits_result, ProviderResult
|
||||||
|
):
|
||||||
|
return inputs
|
||||||
|
prices = {str(row.get("ts_code") or ""): row for row in limits_result.rows}
|
||||||
|
previous = {
|
||||||
|
str(row.get("ts_code") or ""): row
|
||||||
|
for row in previous_result.rows
|
||||||
|
} if isinstance(previous_result, ProviderResult) else {}
|
||||||
|
pools: dict[str, list[dict[str, Any]]] = {
|
||||||
|
"limit_up": [],
|
||||||
|
"limit_down": [],
|
||||||
|
"broken": [],
|
||||||
|
}
|
||||||
|
for quote in daily_result.rows:
|
||||||
|
identifier = str(quote.get("ts_code") or "")
|
||||||
|
price = prices.get(identifier) or {}
|
||||||
|
current = _number(quote.get("close"))
|
||||||
|
high = _number(quote.get("high"))
|
||||||
|
up_limit = _number(price.get("up_limit"))
|
||||||
|
down_limit = _number(price.get("down_limit"))
|
||||||
|
event_type = ""
|
||||||
|
if up_limit > 0 and current >= up_limit - 0.001:
|
||||||
|
event_type = "limit_up"
|
||||||
|
elif up_limit > 0 and high >= up_limit - 0.001:
|
||||||
|
event_type = "broken"
|
||||||
|
elif down_limit > 0 and current <= down_limit + 0.001:
|
||||||
|
event_type = "limit_down"
|
||||||
|
if not event_type:
|
||||||
|
continue
|
||||||
|
identity = directory.get(identifier) or {}
|
||||||
|
prior_streak = int(_number(previous.get(identifier, {}).get("limit_times")))
|
||||||
|
pools[event_type].append(
|
||||||
|
{
|
||||||
|
**quote,
|
||||||
|
"name": str(quote.get("name") or identity.get("name") or ""),
|
||||||
|
"industry": str(identity.get("sector") or ""),
|
||||||
|
"first_time": "",
|
||||||
|
"last_time": "",
|
||||||
|
"open_times": 0,
|
||||||
|
"limit_times": prior_streak + 1 if event_type == "limit_up" else 1,
|
||||||
|
"fd_amount": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
metadata = daily_result.metadata
|
||||||
|
return {
|
||||||
|
**inputs,
|
||||||
|
"limit_up": ProviderResult(tuple(pools["limit_up"]), metadata),
|
||||||
|
"limit_down": ProviderResult(tuple(pools["limit_down"]), metadata),
|
||||||
|
"broken": ProviderResult(tuple(pools["broken"]), metadata),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _rows(
|
def _rows(
|
||||||
inputs: dict[str, ProviderResult | dict[str, Any]], key: str
|
inputs: dict[str, ProviderResult | dict[str, Any]], key: str
|
||||||
) -> tuple[dict[str, Any], ...]:
|
) -> tuple[dict[str, Any], ...]:
|
||||||
@@ -74,7 +134,7 @@ def _pool(rows: tuple[dict[str, Any], ...], status: str) -> list[dict[str, Any]]
|
|||||||
result = []
|
result = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
identifier = str(row.get("ts_code") or "")
|
identifier = str(row.get("ts_code") or "")
|
||||||
amount = _number(row.get("amount")) * 1000
|
amount = _amount_yuan(row)
|
||||||
result.append(
|
result.append(
|
||||||
{
|
{
|
||||||
"identifier": identifier,
|
"identifier": identifier,
|
||||||
@@ -238,6 +298,11 @@ def _number(value: Any, default: float = 0.0) -> float:
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _amount_yuan(row: dict[str, Any]) -> float:
|
||||||
|
amount = _number(row.get("amount"))
|
||||||
|
return amount if row.get("amount_unit") == "yuan" else amount * 1000
|
||||||
|
|
||||||
|
|
||||||
def _time(value: Any) -> str:
|
def _time(value: Any) -> str:
|
||||||
text = str(value or "").strip().replace(":", "")
|
text = str(value or "").strip().replace(":", "")
|
||||||
if len(text) < 4 or not text[:4].isdigit():
|
if len(text) < 4 or not text[:4].isdigit():
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ from backend.data.providers.base import ProviderError
|
|||||||
from backend.data.repository import MarketRepository
|
from backend.data.repository import MarketRepository
|
||||||
from backend.data.sentiment import calculate_sentiment
|
from backend.data.sentiment import calculate_sentiment
|
||||||
from backend.database.connection import Database
|
from backend.database.connection import Database
|
||||||
from backend.features.market.snapshot import build_snapshot
|
from backend.features.market.events import apply_event_revisions
|
||||||
|
from backend.features.market.snapshot import build_realtime_inputs, build_snapshot
|
||||||
|
|
||||||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
@@ -94,6 +95,79 @@ class MarketSnapshotService:
|
|||||||
"limit_down": len(snapshot["down_limits"]),
|
"limit_down": len(snapshot["down_limits"]),
|
||||||
"broken": len(snapshot["broken"]),
|
"broken": len(snapshot["broken"]),
|
||||||
"temperature": sentiment["score"],
|
"temperature": sentiment["score"],
|
||||||
|
"source_set": ["tushare", "local"],
|
||||||
|
"output_version": "market-summary-v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
def sync_realtime(
|
||||||
|
self, requested_date: str | None = None, now: datetime | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
clock = now or datetime.now(SHANGHAI)
|
||||||
|
target = _date(requested_date or clock.date().isoformat())
|
||||||
|
if target != clock.date().isoformat():
|
||||||
|
raise SnapshotSyncError("盘中任务只允许同步当前交易日")
|
||||||
|
local_time = clock.time().replace(tzinfo=None)
|
||||||
|
in_window = time(9, 15) <= local_time < time(11, 35) or time(
|
||||||
|
12, 55
|
||||||
|
) <= local_time < time(15, 5)
|
||||||
|
if not in_window:
|
||||||
|
raise SnapshotSyncError("当前不在盘中行情刷新窗口")
|
||||||
|
with self._database.read() as connection:
|
||||||
|
dates = self._repository.open_dates(connection, target, 2)
|
||||||
|
active_count = self._repository.active_stock_count(connection)
|
||||||
|
if len(dates) < 2 or dates[0] != target:
|
||||||
|
raise SnapshotSyncError("当前日期不是有效交易日")
|
||||||
|
if active_count <= 0:
|
||||||
|
raise SnapshotSyncError("请先同步股票目录")
|
||||||
|
try:
|
||||||
|
raw = self._gateway.realtime_snapshot_inputs(target, dates[1])
|
||||||
|
except (ProviderError, MarketDataUnavailable) as exc:
|
||||||
|
raise SnapshotSyncError("盘中行情读取失败,已保留最后成功快照") from exc
|
||||||
|
daily = raw.get("daily")
|
||||||
|
price_limits = raw.get("price_limits")
|
||||||
|
if not isinstance(daily, ProviderResult):
|
||||||
|
raise SnapshotSyncError("盘中行情缺失,已保留最后成功快照")
|
||||||
|
coverage = len(daily.rows) / active_count
|
||||||
|
if coverage < 0.9:
|
||||||
|
raise SnapshotSyncError(
|
||||||
|
f"盘中行情覆盖率仅{coverage * 100:.1f}%,已保留最后成功快照"
|
||||||
|
)
|
||||||
|
if not isinstance(price_limits, ProviderResult):
|
||||||
|
raise SnapshotSyncError("盘中涨跌停价格缺失,已保留最后成功快照")
|
||||||
|
limit_coverage = len(price_limits.rows) / max(len(daily.rows), 1)
|
||||||
|
if limit_coverage < 0.95:
|
||||||
|
raise SnapshotSyncError("盘中涨跌停价格覆盖不足,已保留最后成功快照")
|
||||||
|
inputs = build_realtime_inputs(raw, self._gateway.stock_directory())
|
||||||
|
snapshot = build_snapshot(target, dates[1], inputs)
|
||||||
|
with self._database.read() as connection:
|
||||||
|
rows = self._repository.summaries(connection, dates[1], 250)
|
||||||
|
history = [json.loads(str(row["payload_json"])) for row in rows]
|
||||||
|
sentiment = calculate_sentiment(snapshot, history)
|
||||||
|
snapshot["sentiment"] = sentiment
|
||||||
|
snapshot.update(snapshot["overview"])
|
||||||
|
snapshot["temperature"] = sentiment["score"]
|
||||||
|
observed_at = clock.isoformat(timespec="seconds")
|
||||||
|
with self._database.transaction() as connection:
|
||||||
|
self._repository.save_summary(
|
||||||
|
connection,
|
||||||
|
trade_date=target,
|
||||||
|
observed_at=observed_at,
|
||||||
|
state=SnapshotState.REALTIME.value,
|
||||||
|
source="tushare",
|
||||||
|
coverage=min(coverage, 1),
|
||||||
|
payload=snapshot,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"trade_date": target,
|
||||||
|
"observed_at": observed_at,
|
||||||
|
"coverage": round(min(coverage, 1), 4),
|
||||||
|
"stocks": len(daily.rows),
|
||||||
|
"limit_up": len(snapshot["limits"]),
|
||||||
|
"limit_down": len(snapshot["down_limits"]),
|
||||||
|
"broken": len(snapshot["broken"]),
|
||||||
|
"temperature": sentiment["score"],
|
||||||
|
"source_set": ["tushare", "local"],
|
||||||
|
"output_version": "market-summary-realtime-v1",
|
||||||
}
|
}
|
||||||
|
|
||||||
def workspace(self, key: str, requested_date: str | None = None) -> dict[str, Any]:
|
def workspace(self, key: str, requested_date: str | None = None) -> dict[str, Any]:
|
||||||
@@ -104,6 +178,9 @@ class MarketSnapshotService:
|
|||||||
if row is None:
|
if row is None:
|
||||||
return {"trade_date": None, "message": "等待管理员首次同步真实收盘行情"}
|
return {"trade_date": None, "message": "等待管理员首次同步真实收盘行情"}
|
||||||
payload = json.loads(str(row["payload_json"]))
|
payload = json.loads(str(row["payload_json"]))
|
||||||
|
with self._database.read() as connection:
|
||||||
|
revisions = self._repository.event_revisions(connection, str(row["trade_date"]))
|
||||||
|
apply_event_revisions(payload, revisions)
|
||||||
response: dict[str, Any] = {
|
response: dict[str, Any] = {
|
||||||
"trade_date": str(row["trade_date"]),
|
"trade_date": str(row["trade_date"]),
|
||||||
"observed_at": str(row["observed_at"]),
|
"observed_at": str(row["observed_at"]),
|
||||||
@@ -173,6 +250,12 @@ class MarketSnapshotService:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
factors = json.loads(str(factor_row["payload_json"])) if factor_row else {}
|
factors = json.loads(str(factor_row["payload_json"])) if factor_row else {}
|
||||||
snapshot = json.loads(str(summary["payload_json"])) if summary else {}
|
snapshot = json.loads(str(summary["payload_json"])) if summary else {}
|
||||||
|
if summary:
|
||||||
|
with self._database.read() as connection:
|
||||||
|
revisions = self._repository.event_revisions(
|
||||||
|
connection, str(summary["trade_date"])
|
||||||
|
)
|
||||||
|
apply_event_revisions(snapshot, revisions)
|
||||||
event = (
|
event = (
|
||||||
_entity_event(snapshot, entity.identifier, entity.code)
|
_entity_event(snapshot, entity.identifier, entity.code)
|
||||||
if entity_type == "stock"
|
if entity_type == "stock"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from backend.features.operations.service import OperationsService
|
||||||
|
|
||||||
|
__all__ = ["OperationsService"]
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Path, Query, Request
|
||||||
|
|
||||||
|
from backend.data.gateway import MarketDataUnavailable
|
||||||
|
from backend.data.providers.base import ProviderError
|
||||||
|
from backend.features.accounts.auth import (
|
||||||
|
AdminPrincipal,
|
||||||
|
AdminWritePrincipal,
|
||||||
|
AuthenticatedPrincipal,
|
||||||
|
)
|
||||||
|
from backend.features.market.events import MarketEventError
|
||||||
|
from backend.features.operations.schemas import (
|
||||||
|
BackfillInput,
|
||||||
|
EventRevisionInput,
|
||||||
|
JobRunResponse,
|
||||||
|
OperationResult,
|
||||||
|
RuntimeStatusResponse,
|
||||||
|
)
|
||||||
|
from backend.features.operations.service import OperationsError
|
||||||
|
from backend.http.errors import AppError
|
||||||
|
|
||||||
|
router = APIRouter(tags=["operations"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/operations/status", response_model=RuntimeStatusResponse)
|
||||||
|
def public_status(request: Request, _principal: AuthenticatedPrincipal) -> dict:
|
||||||
|
return request.app.state.container.operations.public_status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/operations/jobs", response_model=list[JobRunResponse])
|
||||||
|
def jobs(request: Request, _principal: AdminPrincipal) -> list[dict]:
|
||||||
|
return request.app.state.container.operations.jobs()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/operations/backfill", response_model=OperationResult)
|
||||||
|
def backfill(
|
||||||
|
payload: BackfillInput, request: Request, _principal: AdminWritePrincipal
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
return request.app.state.container.operations.backfill(
|
||||||
|
payload.start_date.isoformat(), payload.end_date.isoformat()
|
||||||
|
)
|
||||||
|
except OperationsError as exc:
|
||||||
|
raise AppError("invalid_operation", str(exc), 400) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/operations/events/supplement", response_model=OperationResult)
|
||||||
|
def supplement_events(
|
||||||
|
request: Request,
|
||||||
|
_principal: AdminWritePrincipal,
|
||||||
|
trade_date: Annotated[str, Query(alias="date")],
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
return request.app.state.container.operations.supplement_events(trade_date)
|
||||||
|
except (OperationsError, MarketEventError) as exc:
|
||||||
|
raise AppError("invalid_operation", str(exc), 400) from exc
|
||||||
|
except (MarketDataUnavailable, ProviderError) as exc:
|
||||||
|
raise AppError("event_service_unavailable", str(exc), 503) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/admin/operations/events/{trade_date}/{identifier}",
|
||||||
|
response_model=OperationResult,
|
||||||
|
)
|
||||||
|
def revise_event(
|
||||||
|
payload: EventRevisionInput,
|
||||||
|
request: Request,
|
||||||
|
principal: AdminWritePrincipal,
|
||||||
|
trade_date: Annotated[str, Path()],
|
||||||
|
identifier: Annotated[str, Path(min_length=1, max_length=20)],
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
return request.app.state.container.operations.revise_event(
|
||||||
|
principal.user.id,
|
||||||
|
trade_date=trade_date,
|
||||||
|
identifier=identifier,
|
||||||
|
**payload.model_dump(),
|
||||||
|
)
|
||||||
|
except (OperationsError, MarketEventError) as exc:
|
||||||
|
raise AppError("invalid_event_revision", str(exc), 400) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/admin/operations/events/{trade_date}/{identifier}/history",
|
||||||
|
response_model=list[OperationResult],
|
||||||
|
)
|
||||||
|
def event_history(
|
||||||
|
request: Request,
|
||||||
|
_principal: AdminPrincipal,
|
||||||
|
trade_date: Annotated[str, Path()],
|
||||||
|
identifier: Annotated[str, Path(min_length=1, max_length=20)],
|
||||||
|
) -> list[dict]:
|
||||||
|
return request.app.state.container.operations.event_history(trade_date, identifier)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class BackfillInput(BaseModel):
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
|
||||||
|
|
||||||
|
class EventRevisionInput(BaseModel):
|
||||||
|
event_type: Literal["limit_up", "broken", "limit_down"]
|
||||||
|
reason: str = Field(min_length=1, max_length=200)
|
||||||
|
first_time: str = Field(default="", max_length=5)
|
||||||
|
last_time: str = Field(default="", max_length=5)
|
||||||
|
open_times: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class OperationResult(BaseModel):
|
||||||
|
model_config = {"extra": "allow"}
|
||||||
|
|
||||||
|
|
||||||
|
class JobRunResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
kind: str
|
||||||
|
run_key: str
|
||||||
|
requested_date: str
|
||||||
|
trigger: str
|
||||||
|
status: Literal["running", "completed", "failed"]
|
||||||
|
attempt: int
|
||||||
|
started_at: str
|
||||||
|
finished_at: str | None
|
||||||
|
duration_ms: int | None
|
||||||
|
coverage: float | None
|
||||||
|
source_set: list[str]
|
||||||
|
output_version: str
|
||||||
|
payload: dict[str, Any]
|
||||||
|
error_code: str
|
||||||
|
error_message: str
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeStatusResponse(BaseModel):
|
||||||
|
state: Literal["idle", "running", "ready", "degraded"]
|
||||||
|
message: str
|
||||||
|
trade_date: str | None = None
|
||||||
|
observed_at: str | None = None
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, time
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from backend.data.repository import MarketRepository
|
||||||
|
from backend.database.connection import Database
|
||||||
|
from backend.features.market.events import MarketEventService
|
||||||
|
from backend.features.market.service import MarketService
|
||||||
|
from backend.features.screener.service import ScreenerService
|
||||||
|
from backend.jobs.service import JobAlreadyRunning, JobService
|
||||||
|
|
||||||
|
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
class OperationsError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OperationsService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
database: Database,
|
||||||
|
market_repository: MarketRepository,
|
||||||
|
market: MarketService,
|
||||||
|
events: MarketEventService,
|
||||||
|
screener: ScreenerService,
|
||||||
|
jobs: JobService,
|
||||||
|
) -> None:
|
||||||
|
self._database = database
|
||||||
|
self._market_repository = market_repository
|
||||||
|
self._market = market
|
||||||
|
self._events = events
|
||||||
|
self._screener = screener
|
||||||
|
self._jobs = jobs
|
||||||
|
|
||||||
|
def refresh(
|
||||||
|
self,
|
||||||
|
requested_date: str | None = None,
|
||||||
|
*,
|
||||||
|
trigger: str = "administrator",
|
||||||
|
realtime: bool = False,
|
||||||
|
now: datetime | None = None,
|
||||||
|
run_key: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
clock = now or datetime.now(SHANGHAI)
|
||||||
|
target = _date(requested_date or clock.date().isoformat())
|
||||||
|
key = run_key or f"{target}:{trigger}:{uuid4().hex}"
|
||||||
|
try:
|
||||||
|
return self._jobs.execute(
|
||||||
|
kind="market.refresh",
|
||||||
|
run_key=key,
|
||||||
|
requested_date=target,
|
||||||
|
trigger=trigger,
|
||||||
|
operation=lambda: (
|
||||||
|
self._market.sync_realtime(target, clock)
|
||||||
|
if realtime
|
||||||
|
else self._market.sync_snapshot(target)
|
||||||
|
),
|
||||||
|
stale_after_seconds=120,
|
||||||
|
)
|
||||||
|
except JobAlreadyRunning as exc:
|
||||||
|
raise OperationsError("行情刷新任务正在运行") from exc
|
||||||
|
|
||||||
|
def backfill(self, start_date: str, end_date: str) -> dict[str, Any]:
|
||||||
|
start, end = _date(start_date), _date(end_date)
|
||||||
|
if start > end:
|
||||||
|
raise OperationsError("开始日期不能晚于结束日期")
|
||||||
|
with self._database.read() as connection:
|
||||||
|
dates = self._market_repository.open_dates_between(connection, start, end)
|
||||||
|
if not dates:
|
||||||
|
raise OperationsError("所选范围没有交易日")
|
||||||
|
if len(dates) > 15:
|
||||||
|
raise OperationsError("单次最多回补15个交易日")
|
||||||
|
|
||||||
|
def operation() -> dict[str, Any]:
|
||||||
|
rows = []
|
||||||
|
for target in dates:
|
||||||
|
rows.append(
|
||||||
|
self.refresh(
|
||||||
|
target,
|
||||||
|
trigger="historical-backfill",
|
||||||
|
run_key=f"{target}:archive-v1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"start_date": start,
|
||||||
|
"end_date": end,
|
||||||
|
"dates": list(dates),
|
||||||
|
"completed": len(rows),
|
||||||
|
"coverage": min(
|
||||||
|
(float(row.get("coverage") or 0) for row in rows), default=0
|
||||||
|
),
|
||||||
|
"source_set": ["tushare", "local"],
|
||||||
|
"output_version": "historical-backfill-v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._jobs.execute(
|
||||||
|
kind="market.backfill",
|
||||||
|
run_key=f"{start}:{end}",
|
||||||
|
requested_date=end,
|
||||||
|
trigger="administrator",
|
||||||
|
operation=operation,
|
||||||
|
stale_after_seconds=15 * 60,
|
||||||
|
)
|
||||||
|
except JobAlreadyRunning as exc:
|
||||||
|
raise OperationsError("历史回补任务正在运行") from exc
|
||||||
|
|
||||||
|
def supplement_events(
|
||||||
|
self,
|
||||||
|
trade_date: str,
|
||||||
|
*,
|
||||||
|
trigger: str = "administrator",
|
||||||
|
run_key: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
target = _date(trade_date)
|
||||||
|
try:
|
||||||
|
return self._jobs.execute(
|
||||||
|
kind="market.event-supplement",
|
||||||
|
run_key=run_key or f"{target}:event-v1:{uuid4().hex}",
|
||||||
|
requested_date=target,
|
||||||
|
trigger=trigger,
|
||||||
|
operation=lambda: self._events.supplement(target),
|
||||||
|
stale_after_seconds=120,
|
||||||
|
)
|
||||||
|
except JobAlreadyRunning as exc:
|
||||||
|
raise OperationsError("事件补充任务正在运行") from exc
|
||||||
|
|
||||||
|
def revise_event(self, user_id: int, **payload: Any) -> dict[str, Any]:
|
||||||
|
return self._events.revise(user_id=user_id, **payload)
|
||||||
|
|
||||||
|
def event_history(self, trade_date: str, identifier: str) -> list[dict[str, Any]]:
|
||||||
|
return self._events.history(trade_date, identifier)
|
||||||
|
|
||||||
|
def jobs(self) -> list[dict[str, Any]]:
|
||||||
|
return self._jobs.latest()
|
||||||
|
|
||||||
|
def public_status(self) -> dict[str, Any]:
|
||||||
|
latest = self._jobs.latest_for_kind("market.refresh")
|
||||||
|
with self._database.read() as connection:
|
||||||
|
snapshot = self._market_repository.latest_summary(
|
||||||
|
connection, datetime.now(SHANGHAI).date().isoformat()
|
||||||
|
)
|
||||||
|
if latest and latest["status"] == "running":
|
||||||
|
return {"state": "running", "message": "正在更新行情数据"}
|
||||||
|
if snapshot is None:
|
||||||
|
message = "等待管理员首次同步真实行情"
|
||||||
|
if latest and latest["status"] == "failed":
|
||||||
|
message = "最近行情更新失败,等待重新同步"
|
||||||
|
return {"state": "degraded", "message": message}
|
||||||
|
trade_date = str(snapshot["trade_date"])
|
||||||
|
observed_at = str(snapshot["observed_at"])
|
||||||
|
if latest and latest["status"] == "failed":
|
||||||
|
message = "最近更新失败,继续使用最后成功行情"
|
||||||
|
state = "degraded"
|
||||||
|
elif str(snapshot["state"]) == "realtime":
|
||||||
|
message = f"实时行情更新于 {observed_at[11:19]}"
|
||||||
|
state = "ready"
|
||||||
|
else:
|
||||||
|
message = f"{trade_date} 收盘数据已就绪"
|
||||||
|
state = "ready"
|
||||||
|
return {
|
||||||
|
"state": state,
|
||||||
|
"message": message,
|
||||||
|
"trade_date": trade_date,
|
||||||
|
"observed_at": observed_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
def tick(self, now: datetime | None = None) -> None:
|
||||||
|
clock = now or datetime.now(SHANGHAI)
|
||||||
|
target = clock.date().isoformat()
|
||||||
|
local_time = clock.time().replace(tzinfo=None)
|
||||||
|
in_market = time(9, 15) <= local_time < time(11, 35) or time(
|
||||||
|
12, 55
|
||||||
|
) <= local_time < time(15, 5)
|
||||||
|
is_open = self._is_open(target)
|
||||||
|
if (
|
||||||
|
in_market
|
||||||
|
and is_open
|
||||||
|
and self._jobs.ready_for_schedule(
|
||||||
|
"market.refresh",
|
||||||
|
now=clock,
|
||||||
|
completed_after_seconds=8,
|
||||||
|
failed_after_seconds=60,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
bucket = int(clock.timestamp() // 8)
|
||||||
|
self._attempt(
|
||||||
|
lambda: self.refresh(
|
||||||
|
target,
|
||||||
|
trigger="realtime-poll",
|
||||||
|
realtime=True,
|
||||||
|
now=clock,
|
||||||
|
run_key=f"{target}:realtime:{bucket}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
time(9, 15) <= local_time < time(9, 30)
|
||||||
|
and is_open
|
||||||
|
and self._jobs.ready_for_schedule(
|
||||||
|
"auction.collect",
|
||||||
|
now=clock,
|
||||||
|
completed_after_seconds=8,
|
||||||
|
failed_after_seconds=60,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
bucket = int(clock.timestamp() // 8)
|
||||||
|
self._attempt(
|
||||||
|
lambda: self._jobs.execute(
|
||||||
|
kind="auction.collect",
|
||||||
|
run_key=f"{target}:{bucket}",
|
||||||
|
requested_date=target,
|
||||||
|
trigger="auction-poll",
|
||||||
|
operation=lambda: self._auction_result(target, clock),
|
||||||
|
stale_after_seconds=60,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if local_time >= time(15, 10) and is_open:
|
||||||
|
final = self._jobs.latest_success("market.refresh", target)
|
||||||
|
if not final or final["payload"].get("output_version") != "market-summary-v1":
|
||||||
|
if self._jobs.ready_for_schedule(
|
||||||
|
"market.refresh",
|
||||||
|
now=clock,
|
||||||
|
completed_after_seconds=0,
|
||||||
|
failed_after_seconds=300,
|
||||||
|
):
|
||||||
|
retry_bucket = int(clock.timestamp() // 300)
|
||||||
|
self._attempt(
|
||||||
|
lambda: self.refresh(
|
||||||
|
target,
|
||||||
|
trigger="after-close",
|
||||||
|
run_key=f"{target}:final:{retry_bucket}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._after_close(target, clock)
|
||||||
|
|
||||||
|
def _after_close(self, target: str, clock: datetime) -> None:
|
||||||
|
if (
|
||||||
|
not self._jobs.latest_success("market.event-supplement", target)
|
||||||
|
and self._jobs.ready_for_schedule(
|
||||||
|
"market.event-supplement",
|
||||||
|
now=clock,
|
||||||
|
completed_after_seconds=300,
|
||||||
|
failed_after_seconds=300,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
self._attempt(
|
||||||
|
lambda: self.supplement_events(
|
||||||
|
target, trigger="after-close", run_key=f"{target}:event-v1"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not self._jobs.latest_success("screener.after-close", target)
|
||||||
|
and self._jobs.ready_for_schedule(
|
||||||
|
"screener.after-close",
|
||||||
|
now=clock,
|
||||||
|
completed_after_seconds=300,
|
||||||
|
failed_after_seconds=300,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
self._attempt(
|
||||||
|
lambda: self._jobs.execute(
|
||||||
|
kind="screener.after-close",
|
||||||
|
run_key=f"{target}:catalog-v1",
|
||||||
|
requested_date=target,
|
||||||
|
trigger="after-close",
|
||||||
|
operation=self._screener_result,
|
||||||
|
stale_after_seconds=15 * 60,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _auction_result(self, target: str, clock: datetime) -> dict[str, Any]:
|
||||||
|
result = self._market.sync_insight("auction", target, 0)
|
||||||
|
return {
|
||||||
|
"trade_date": result.get("trade_date") or target,
|
||||||
|
"coverage": result.get("coverage"),
|
||||||
|
"phase": result.get("phase"),
|
||||||
|
"observed_at": result.get("observed_at") or clock.isoformat(timespec="seconds"),
|
||||||
|
"source_set": ["ifind", "tushare", "local"],
|
||||||
|
"output_version": "auction-snapshot-v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _screener_result(self) -> dict[str, Any]:
|
||||||
|
result = self._screener.run_after_close() or {}
|
||||||
|
return {
|
||||||
|
**result,
|
||||||
|
"coverage": result.get("coverage"),
|
||||||
|
"source_set": ["tushare", "local"],
|
||||||
|
"output_version": "screener-runs-v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _is_open(self, target: str) -> bool:
|
||||||
|
with self._database.read() as connection:
|
||||||
|
return target in self._market_repository.open_dates(connection, target, 1)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _attempt(operation) -> None:
|
||||||
|
try:
|
||||||
|
operation()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _date(value: str) -> str:
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value).isoformat()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise OperationsError("日期格式无效") from exc
|
||||||
@@ -4,6 +4,7 @@ from backend.features.accounts.routes import router as accounts_router
|
|||||||
from backend.features.heaven.routes import router as heaven_router
|
from backend.features.heaven.routes import router as heaven_router
|
||||||
from backend.features.market.routes import router as market_router
|
from backend.features.market.routes import router as market_router
|
||||||
from backend.features.mentor.routes import router as mentor_router
|
from backend.features.mentor.routes import router as mentor_router
|
||||||
|
from backend.features.operations.routes import router as operations_router
|
||||||
from backend.features.review.routes import router as review_router
|
from backend.features.review.routes import router as review_router
|
||||||
from backend.features.screener.routes import router as screener_router
|
from backend.features.screener.routes import router as screener_router
|
||||||
from backend.http.routes.health import router as health_router
|
from backend.http.routes.health import router as health_router
|
||||||
@@ -16,3 +17,4 @@ api_router.include_router(screener_router)
|
|||||||
api_router.include_router(mentor_router)
|
api_router.include_router(mentor_router)
|
||||||
api_router.include_router(heaven_router)
|
api_router.include_router(heaven_router)
|
||||||
api_router.include_router(review_router)
|
api_router.include_router(review_router)
|
||||||
|
api_router.include_router(operations_router)
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from backend.features.operations.service import OperationsService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_operations_scheduler(
|
||||||
|
service: OperationsService, stop: asyncio.Event
|
||||||
|
) -> None:
|
||||||
|
while not stop.is_set():
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(service.tick)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Operations scheduler tick failed",
|
||||||
|
extra={"event": "operations.scheduler.failed"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(stop.wait(), timeout=5)
|
||||||
|
except TimeoutError:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class JobRepository:
|
||||||
|
def begin(
|
||||||
|
self,
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
run_key: str,
|
||||||
|
requested_date: str,
|
||||||
|
trigger: str,
|
||||||
|
started_at: datetime,
|
||||||
|
stale_after_seconds: int,
|
||||||
|
) -> sqlite3.Row:
|
||||||
|
stale_before = (started_at - timedelta(seconds=stale_after_seconds)).isoformat(
|
||||||
|
timespec="seconds"
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE job_runs SET status = 'failed', finished_at = ?, duration_ms = ?,
|
||||||
|
error_code = 'stale_job', error_message = '任务进程中断或超过最长运行时间'
|
||||||
|
WHERE kind = ? AND status = 'running' AND started_at < ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
started_at.isoformat(timespec="seconds"),
|
||||||
|
stale_after_seconds * 1000,
|
||||||
|
kind,
|
||||||
|
stale_before,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
attempt_row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(MAX(attempt), 0) + 1 AS attempt
|
||||||
|
FROM job_runs WHERE kind = ? AND run_key = ?
|
||||||
|
""",
|
||||||
|
(kind, run_key),
|
||||||
|
).fetchone()
|
||||||
|
attempt = int(attempt_row["attempt"] if attempt_row else 1)
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO job_runs (
|
||||||
|
kind, run_key, requested_date, trigger, status, attempt, started_at
|
||||||
|
) VALUES (?, ?, ?, ?, 'running', ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
kind,
|
||||||
|
run_key,
|
||||||
|
requested_date,
|
||||||
|
trigger,
|
||||||
|
attempt,
|
||||||
|
started_at.isoformat(timespec="seconds"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return connection.execute(
|
||||||
|
"SELECT * FROM job_runs WHERE id = ?", (cursor.lastrowid,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
def finish(
|
||||||
|
self,
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
run_id: int,
|
||||||
|
*,
|
||||||
|
finished_at: datetime,
|
||||||
|
duration_ms: int,
|
||||||
|
coverage: float | None,
|
||||||
|
source_set: list[str],
|
||||||
|
output_version: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE job_runs SET status = 'completed', finished_at = ?, duration_ms = ?,
|
||||||
|
coverage = ?, source_set_json = ?, output_version = ?, payload_json = ?
|
||||||
|
WHERE id = ? AND status = 'running'
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
finished_at.isoformat(timespec="seconds"),
|
||||||
|
duration_ms,
|
||||||
|
coverage,
|
||||||
|
_json(source_set),
|
||||||
|
output_version,
|
||||||
|
_json(payload),
|
||||||
|
run_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def fail(
|
||||||
|
self,
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
run_id: int,
|
||||||
|
*,
|
||||||
|
finished_at: datetime,
|
||||||
|
duration_ms: int,
|
||||||
|
error_code: str,
|
||||||
|
error_message: str,
|
||||||
|
) -> None:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE job_runs SET status = 'failed', finished_at = ?, duration_ms = ?,
|
||||||
|
error_code = ?, error_message = ? WHERE id = ? AND status = 'running'
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
finished_at.isoformat(timespec="seconds"),
|
||||||
|
duration_ms,
|
||||||
|
error_code,
|
||||||
|
error_message[:500],
|
||||||
|
run_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def latest(self, connection: sqlite3.Connection, limit: int = 40) -> tuple[sqlite3.Row, ...]:
|
||||||
|
return tuple(
|
||||||
|
connection.execute(
|
||||||
|
"SELECT * FROM job_runs ORDER BY started_at DESC, id DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
)
|
||||||
|
|
||||||
|
def latest_for_kind(
|
||||||
|
self, connection: sqlite3.Connection, kind: str
|
||||||
|
) -> sqlite3.Row | None:
|
||||||
|
return connection.execute(
|
||||||
|
"SELECT * FROM job_runs WHERE kind = ? ORDER BY started_at DESC, id DESC LIMIT 1",
|
||||||
|
(kind,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
def latest_success(
|
||||||
|
self, connection: sqlite3.Connection, kind: str, requested_date: str = ""
|
||||||
|
) -> sqlite3.Row | None:
|
||||||
|
return connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM job_runs
|
||||||
|
WHERE kind = ? AND status = 'completed' AND (? = '' OR requested_date = ?)
|
||||||
|
ORDER BY finished_at DESC, id DESC LIMIT 1
|
||||||
|
""",
|
||||||
|
(kind, requested_date, requested_date),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def public_job(row: sqlite3.Row) -> dict[str, Any]:
|
||||||
|
result = dict(row)
|
||||||
|
result["source_set"] = json.loads(str(result.pop("source_set_json")))
|
||||||
|
result["payload"] = json.loads(str(result.pop("payload_json")))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _json(value: Any) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from backend.features.screener.service import ScreenerError, ScreenerService
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def run_screener_scheduler(service: ScreenerService, stop: asyncio.Event) -> None:
|
|
||||||
while not stop.is_set():
|
|
||||||
try:
|
|
||||||
result = await asyncio.to_thread(service.run_after_close)
|
|
||||||
if result:
|
|
||||||
logger.info(
|
|
||||||
"Screener after-close run completed",
|
|
||||||
extra={"event": "screener.completed", "context": result},
|
|
||||||
)
|
|
||||||
except ScreenerError as exc:
|
|
||||||
logger.info(
|
|
||||||
"Screener is waiting for complete market data",
|
|
||||||
extra={"event": "screener.waiting", "context": {"reason": str(exc)}},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Screener scheduler failed",
|
|
||||||
extra={"event": "screener.failed"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(stop.wait(), timeout=300)
|
|
||||||
except TimeoutError:
|
|
||||||
pass
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from backend.database.connection import Database
|
||||||
|
from backend.jobs.repository import JobRepository, public_job
|
||||||
|
|
||||||
|
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
class JobAlreadyRunning(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class JobService:
|
||||||
|
def __init__(self, database: Database, repository: JobRepository) -> None:
|
||||||
|
self._database = database
|
||||||
|
self._repository = repository
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
run_key: str,
|
||||||
|
requested_date: str,
|
||||||
|
trigger: str,
|
||||||
|
operation: Callable[[], dict[str, Any]],
|
||||||
|
stale_after_seconds: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
started = datetime.now(SHANGHAI)
|
||||||
|
try:
|
||||||
|
with self._database.transaction() as connection:
|
||||||
|
row = self._repository.begin(
|
||||||
|
connection,
|
||||||
|
kind=kind,
|
||||||
|
run_key=run_key,
|
||||||
|
requested_date=requested_date,
|
||||||
|
trigger=trigger,
|
||||||
|
started_at=started,
|
||||||
|
stale_after_seconds=stale_after_seconds,
|
||||||
|
)
|
||||||
|
except sqlite3.IntegrityError as exc:
|
||||||
|
raise JobAlreadyRunning("同类任务正在运行") from exc
|
||||||
|
run_id = int(row["id"])
|
||||||
|
try:
|
||||||
|
result = operation()
|
||||||
|
except Exception as exc:
|
||||||
|
finished = datetime.now(SHANGHAI)
|
||||||
|
with self._database.transaction() as connection:
|
||||||
|
self._repository.fail(
|
||||||
|
connection,
|
||||||
|
run_id,
|
||||||
|
finished_at=finished,
|
||||||
|
duration_ms=_duration(started, finished),
|
||||||
|
error_code=type(exc).__name__,
|
||||||
|
error_message=str(exc) or "任务执行失败",
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finished = datetime.now(SHANGHAI)
|
||||||
|
coverage = result.get("coverage")
|
||||||
|
with self._database.transaction() as connection:
|
||||||
|
self._repository.finish(
|
||||||
|
connection,
|
||||||
|
run_id,
|
||||||
|
finished_at=finished,
|
||||||
|
duration_ms=_duration(started, finished),
|
||||||
|
coverage=float(coverage) if isinstance(coverage, (int, float)) else None,
|
||||||
|
source_set=[str(value) for value in result.get("source_set") or []],
|
||||||
|
output_version=str(result.get("output_version") or ""),
|
||||||
|
payload=result,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def latest(self, limit: int = 40) -> list[dict[str, Any]]:
|
||||||
|
with self._database.read() as connection:
|
||||||
|
return [public_job(row) for row in self._repository.latest(connection, limit)]
|
||||||
|
|
||||||
|
def latest_for_kind(self, kind: str) -> dict[str, Any] | None:
|
||||||
|
with self._database.read() as connection:
|
||||||
|
row = self._repository.latest_for_kind(connection, kind)
|
||||||
|
return public_job(row) if row else None
|
||||||
|
|
||||||
|
def latest_success(self, kind: str, requested_date: str = "") -> dict[str, Any] | None:
|
||||||
|
with self._database.read() as connection:
|
||||||
|
row = self._repository.latest_success(connection, kind, requested_date)
|
||||||
|
return public_job(row) if row else None
|
||||||
|
|
||||||
|
def ready_for_schedule(
|
||||||
|
self,
|
||||||
|
kind: str,
|
||||||
|
*,
|
||||||
|
now: datetime,
|
||||||
|
completed_after_seconds: int,
|
||||||
|
failed_after_seconds: int,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether a scheduler may start another run of this job kind."""
|
||||||
|
latest = self.latest_for_kind(kind)
|
||||||
|
if latest is None:
|
||||||
|
return True
|
||||||
|
if latest["status"] == "running":
|
||||||
|
return False
|
||||||
|
reference = latest.get("finished_at") or latest.get("started_at")
|
||||||
|
if not reference:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
elapsed = (now - datetime.fromisoformat(str(reference))).total_seconds()
|
||||||
|
except ValueError:
|
||||||
|
return True
|
||||||
|
cooldown = (
|
||||||
|
completed_after_seconds
|
||||||
|
if latest["status"] == "completed"
|
||||||
|
else failed_after_seconds
|
||||||
|
)
|
||||||
|
return elapsed >= cooldown
|
||||||
|
|
||||||
|
|
||||||
|
def _duration(started: datetime, finished: datetime) -> int:
|
||||||
|
return max(0, round((finished - started).total_seconds() * 1000))
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
|
|
||||||
## 减法结果
|
## 减法结果
|
||||||
|
|
||||||
- 旧运行时代码:55个文件、61,793行;重建运行时代码:206个职责文件、25,151行。
|
- 旧运行时代码:55个文件、61,793行;当前重建运行时代码:216个职责文件、24,581行。
|
||||||
- 运行时代码净减少36,642行,约59.3%;迁移/备份工具、测试和文档未混入运行时比较。
|
- 运行时代码净减少37,212行,约60.2%;迁移/备份工具、测试和文档未混入运行时比较。
|
||||||
- 唯一浏览器API、数据网关、LLM网关、弹窗Host、设计令牌和移动规则均通过扫描。
|
- 唯一浏览器API、数据网关、LLM网关、弹窗Host、设计令牌和移动规则均通过扫描。
|
||||||
- LLM网关原有3个账户领域具体类型反向导入已改为最小Protocol,未增加第二套服务。
|
- LLM网关原有3个账户领域具体类型反向导入已改为最小Protocol,未增加第二套服务。
|
||||||
- 超过章程建议行数的算法、Provider和CSS已逐项登记保留原因及拆分触发条件,见`../../final/subtraction-audit.md`。
|
- 超过章程建议行数的算法、Provider和CSS已逐项登记保留原因及拆分触发条件,见`../../final/subtraction-audit.md`。
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
## 维护与回退演练
|
## 维护与回退演练
|
||||||
|
|
||||||
- 已在真实迁移库副本执行schema 10 -> 9 -> 10,完整性及外键检查通过;明确验证数据回退只能在副本执行。
|
- 已在真实迁移库副本执行schema 10 -> 9 -> 10,完整性及外键检查通过;明确验证数据回退只能在副本执行。
|
||||||
- 演练发现并修复`tools.database status`对已有migration误报未知版本的问题;状态查询现在只读,真实副本返回`schema_version=10`。
|
- 演练发现并修复`tools.database status`对已有migration误报未知版本的问题;状态查询保持只读;运行治理迁移后当前版本为`schema_version=11`。
|
||||||
- 人工维护入口、常见改动路径、数据源标准、故障定位与复杂度红线见`../../final/maintenance-guide.md`。
|
- 人工维护入口、常见改动路径、数据源标准、故障定位与复杂度红线见`../../final/maintenance-guide.md`。
|
||||||
- NAS切换、观察和回退动作见`../../final/cutover-checklist.md`;正式容器未变更。
|
- NAS切换、观察和回退动作见`../../final/cutover-checklist.md`;正式容器未变更。
|
||||||
|
|
||||||
@@ -28,20 +28,20 @@
|
|||||||
- 生产响应统一设置CSP、`nosniff`、拒绝Frame、Permissions Policy、Referrer Policy;HTTPS增加HSTS。
|
- 生产响应统一设置CSP、`nosniff`、拒绝Frame、Permissions Policy、Referrer Policy;HTTPS增加HSTS。
|
||||||
- CSP保留Vue动态宽度样式所需的`style-src 'unsafe-inline'`,脚本仍只允许同源。
|
- CSP保留Vue动态宽度样式所需的`style-src 'unsafe-inline'`,脚本仍只允许同源。
|
||||||
- 静态哈希资源长期缓存,SPA入口不缓存;未知API不被SPA接管。
|
- 静态哈希资源长期缓存,SPA入口不缓存;未知API不被SPA接管。
|
||||||
- 真实迁移库副本市场摘要中位数20.90ms、P95 23.55ms;前端生产JS 279.23KB、CSS 89.25KB(未压缩)。
|
- 真实迁移库副本市场摘要中位数20.90ms、P95 23.55ms;前端生产JS 296.29KB、CSS 92.08KB(未压缩)。
|
||||||
|
|
||||||
## 最终门禁
|
## 最终门禁
|
||||||
|
|
||||||
- Ruff:通过。
|
- Ruff:通过。
|
||||||
- pytest:99项通过。
|
- pytest:104项通过。
|
||||||
- Vue TypeScript:通过。
|
- Vue TypeScript:通过。
|
||||||
- Vitest:3个文件、7项通过。
|
- Vitest:3个文件、7项通过。
|
||||||
- Vite生产构建:通过。
|
- Vite生产构建:通过。
|
||||||
- Playwright:17项通过,单worker,最后一轮耗时约1分钟。
|
- Playwright:20项通过,单worker,最后一轮耗时约1.7分钟。
|
||||||
- `git diff --check`和已知敏感值扫描:通过。
|
- `git diff --check`和已知敏感值扫描:通过。
|
||||||
- Docker:本机未安装,未虚报实构建;列为NAS切换前阻断项。
|
- Docker:本机未安装,未虚报实构建;列为NAS切换前阻断项。
|
||||||
|
|
||||||
## 结论
|
## 结论
|
||||||
|
|
||||||
从零重建、数据迁移工具、备份恢复、结构治理、移动端、性能安全和人工维护资产已经完成。
|
模块化重建基线、数据迁移工具、备份恢复、运行治理、移动端基线、性能安全和人工维护资产已经建立。
|
||||||
当前代码具备进入独立NAS预发布验证的条件,但未经用户最终确认不会替换正式容器。
|
产品仍有`../../final/completion-audit.md`登记的明确缺口,尚不能宣称完整迁移;未经用户最终确认也不会替换正式容器。
|
||||||
|
|||||||
@@ -25,22 +25,10 @@
|
|||||||
|
|
||||||
### P0:用户可见功能缺失
|
### P0:用户可见功能缺失
|
||||||
|
|
||||||
1. **历史数据回补**:系统管理仍显示“暂不可用”,不符合行情管理的正式功能要求。
|
1. **模型连通性测试**:模型池支持增删改和主辅选择,但每个模型的独立测试入口与服务端测试调用尚未实现。
|
||||||
2. **模型连通性测试**:模型池支持增删改和主辅选择,但每个模型的独立测试入口与服务端测试调用尚未实现。
|
2. **自定义选股完整能力**:缺少自然语言转换受控公式和滚动回测;精选策略详情尚未完整显示评分权重、
|
||||||
3. **事件原因治理**:涨停、炸板、跌停原因的盘后补充、管理员人工修订优先级和修订记录尚未实现。
|
|
||||||
4. **自定义选股完整能力**:缺少自然语言转换受控公式和滚动回测;精选策略详情尚未完整显示评分权重、
|
|
||||||
执行频率和风险等级。
|
执行频率和风险等级。
|
||||||
|
|
||||||
### P0:运行与数据真相缺失
|
|
||||||
|
|
||||||
1. **盘中行情刷新任务**:当前只启动盘后选股调度。交易日 9:15-11:35、12:55-15:05 的行情刷新、
|
|
||||||
约 8 秒节流、单任务互斥和最后成功快照保护尚未形成正式后台任务。
|
|
||||||
2. **任务状态与审计**:行情刷新、竞价采集、盘后选股和事件补充没有统一的开始/完成/失败、覆盖率、
|
|
||||||
输出版本、耗时和重试记录。前端也无法显示真实任务状态。
|
|
||||||
3. **阶段选股四步状态**:已去掉“无运行结果时回退第一条结果”的错误行为,但四步仍未逐步表达未执行、
|
|
||||||
执行中、失败和完成。
|
|
||||||
4. **状态栏真相**:状态栏右侧仍是固定“等待行情数据”,没有反映真实快照或后台任务状态。
|
|
||||||
|
|
||||||
### P1:验收和交互覆盖不足
|
### P1:验收和交互覆盖不足
|
||||||
|
|
||||||
1. 固定案例 `C06`、`C08` 当前为未实现,不能标记通过。
|
1. 固定案例 `C06`、`C08` 当前为未实现,不能标记通过。
|
||||||
@@ -63,6 +51,14 @@
|
|||||||
- 个股详情已包含交易指标、资金流、事件逻辑、自选操作、观势入口和私有个股笔记;题材详情展示成分股。
|
- 个股详情已包含交易指标、资金流、事件逻辑、自选操作、观势入口和私有个股笔记;题材详情展示成分股。
|
||||||
- 股池、天梯、轮动、题材、人气榜、龙虎榜、智能选股、自选、交易日志、策略跟踪和提醒中的股票代码
|
- 股池、天梯、轮动、题材、人气榜、龙虎榜、智能选股、自选、交易日志、策略跟踪和提醒中的股票代码
|
||||||
共用唯一日K/分时悬浮与详情入口;无悬停设备直接进入详情。
|
共用唯一日K/分时悬浮与详情入口;无悬停设备直接进入详情。
|
||||||
|
- 系统管理已提供最多15个交易日的历史回补入口;任务结果和失败原因进入统一任务审计。
|
||||||
|
- 盘中行情刷新、动态竞价、盘后正式刷新、事件补充和盘后选股共用唯一调度器与任务表;同类任务互斥,
|
||||||
|
成功与失败按任务类型统一冷却,外部接口故障不会每5秒重复写失败记录。
|
||||||
|
- 盘中正式计算仅使用Tushare全市场实时行情和官方涨跌停价格;覆盖率不达门槛时拒绝写入并保留最后成功快照。
|
||||||
|
- 涨停、炸板、跌停原因可由iFinD盘后补充,管理员修订具有更高优先级,全部修订历史永久保留。
|
||||||
|
- 阶段选股四步和候选区已直接表达未执行、执行中、失败、已完成;策略库不再把未执行或失败误写为暂无信号。
|
||||||
|
- 状态栏读取真实后台任务与最后成功行情状态,不再显示固定占位文字。
|
||||||
|
- 本轮验证为Ruff、104项pytest、Vue类型检查、7项Vitest、生产构建和20项Playwright全部通过。
|
||||||
|
|
||||||
## 外部环境阻断项
|
## 外部环境阻断项
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
|
|
||||||
|
import { operationsApi } from "../../shared/api/operations";
|
||||||
import { findWorkspace } from "../workspaceRegistry";
|
import { findWorkspace } from "../workspaceRegistry";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -9,12 +10,20 @@ const title = computed(() => {
|
|||||||
if (typeof route.meta.title === "string") return route.meta.title;
|
if (typeof route.meta.title === "string") return route.meta.title;
|
||||||
return findWorkspace(String(route.params.workspace ?? ""))?.title ?? "小白复盘";
|
return findWorkspace(String(route.params.workspace ?? ""))?.title ?? "小白复盘";
|
||||||
});
|
});
|
||||||
|
const runtimeMessage = ref("正在读取行情状态");
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
async function loadStatus(): Promise<void> {
|
||||||
|
try { runtimeMessage.value = (await operationsApi.status()).message; }
|
||||||
|
catch { runtimeMessage.value = "行情状态暂不可用"; }
|
||||||
|
}
|
||||||
|
onMounted(() => { void loadStatus(); timer = setInterval(loadStatus, 15_000); });
|
||||||
|
onBeforeUnmount(() => { if (timer) clearInterval(timer); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<footer class="statusbar">
|
<footer class="statusbar">
|
||||||
<span>{{ title }}</span>
|
<span>{{ title }}</span>
|
||||||
<span class="statusbar-center">股市有风险,投资需谨慎</span>
|
<span class="statusbar-center">股市有风险,投资需谨慎</span>
|
||||||
<span class="statusbar-right">等待行情数据</span>
|
<span class="statusbar-right">{{ runtimeMessage }}</span>
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { onMounted, reactive, ref } from "vue";
|
import { onMounted, reactive, ref } from "vue";
|
||||||
|
|
||||||
import { api } from "../../shared/api/client";
|
import { api } from "../../shared/api/client";
|
||||||
|
import { operationsApi, type JobRun } from "../../shared/api/operations";
|
||||||
|
import { useMarketStore } from "../../shared/stores/market";
|
||||||
import { useUiStore } from "../../shared/stores/ui";
|
import { useUiStore } from "../../shared/stores/ui";
|
||||||
|
|
||||||
type CredentialStatus = { name: string; configured: boolean; updated_at: string | null };
|
type CredentialStatus = { name: string; configured: boolean; updated_at: string | null };
|
||||||
@@ -12,6 +14,7 @@ const labels: Record<string, string> = {
|
|||||||
ifind_access_token: "iFinD Access Token",
|
ifind_access_token: "iFinD Access Token",
|
||||||
};
|
};
|
||||||
const ui = useUiStore();
|
const ui = useUiStore();
|
||||||
|
const market = useMarketStore();
|
||||||
const statuses = ref<CredentialStatus[]>([]);
|
const statuses = ref<CredentialStatus[]>([]);
|
||||||
const values = reactive<Record<string, string>>({});
|
const values = reactive<Record<string, string>>({});
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
@@ -19,11 +22,36 @@ const errorMessage = ref("");
|
|||||||
const syncing = ref(false);
|
const syncing = ref(false);
|
||||||
const syncResult = ref("");
|
const syncResult = ref("");
|
||||||
const syncError = ref("");
|
const syncError = ref("");
|
||||||
|
const jobs = ref<JobRun[]>([]);
|
||||||
|
const backfill = reactive({ start_date: market.selectedDate, end_date: market.selectedDate });
|
||||||
|
const backfilling = ref(false);
|
||||||
|
const operationMessage = ref("");
|
||||||
|
const event = reactive({
|
||||||
|
trade_date: market.selectedDate,
|
||||||
|
identifier: "",
|
||||||
|
event_type: "limit_up" as "limit_up" | "broken" | "limit_down",
|
||||||
|
reason: "",
|
||||||
|
first_time: "",
|
||||||
|
last_time: "",
|
||||||
|
open_times: null as number | null,
|
||||||
|
});
|
||||||
|
const eventBusy = ref(false);
|
||||||
|
const eventHistory = ref<Record<string, unknown>[]>([]);
|
||||||
|
|
||||||
|
const jobLabels: Record<string, string> = {
|
||||||
|
"market.refresh": "行情刷新",
|
||||||
|
"market.backfill": "历史回补",
|
||||||
|
"auction.collect": "竞价采集",
|
||||||
|
"market.event-supplement": "事件补充",
|
||||||
|
"screener.after-close": "盘后选股",
|
||||||
|
};
|
||||||
|
const statusLabels = { running: "执行中", completed: "已完成", failed: "失败" };
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
statuses.value = await api.get<CredentialStatus[]>("/admin/system/credentials");
|
statuses.value = await api.get<CredentialStatus[]>("/admin/system/credentials");
|
||||||
|
jobs.value = await operationsApi.jobs();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error instanceof Error ? error.message : "行情凭据读取失败。";
|
errorMessage.value = error instanceof Error ? error.message : "行情凭据读取失败。";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -31,6 +59,70 @@ async function load(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadJobs(): Promise<void> {
|
||||||
|
try { jobs.value = await operationsApi.jobs(); }
|
||||||
|
catch (error) { ui.showToast(error instanceof Error ? error.message : "任务状态读取失败"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBackfill(): Promise<void> {
|
||||||
|
backfilling.value = true;
|
||||||
|
operationMessage.value = "";
|
||||||
|
try {
|
||||||
|
const result = await operationsApi.backfill(backfill.start_date, backfill.end_date);
|
||||||
|
operationMessage.value = `已完成 ${Number(result.completed ?? 0)} 个交易日回补`;
|
||||||
|
ui.showToast("历史数据回补已完成");
|
||||||
|
await loadJobs();
|
||||||
|
} catch (error) {
|
||||||
|
ui.showToast(error instanceof Error ? error.message : "历史回补失败");
|
||||||
|
} finally { backfilling.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function supplementEvents(): Promise<void> {
|
||||||
|
eventBusy.value = true;
|
||||||
|
try {
|
||||||
|
const result = await operationsApi.supplementEvents(event.trade_date);
|
||||||
|
operationMessage.value = `事件原因已匹配 ${Number(result.matched ?? 0)} 条`;
|
||||||
|
ui.showToast("事件原因补充完成");
|
||||||
|
await loadJobs();
|
||||||
|
} catch (error) {
|
||||||
|
ui.showToast(error instanceof Error ? error.message : "事件补充暂不可用");
|
||||||
|
} finally { eventBusy.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reviseEvent(): Promise<void> {
|
||||||
|
eventBusy.value = true;
|
||||||
|
try {
|
||||||
|
await operationsApi.reviseEvent(event.trade_date, event.identifier.trim(), {
|
||||||
|
event_type: event.event_type,
|
||||||
|
reason: event.reason,
|
||||||
|
first_time: event.first_time,
|
||||||
|
last_time: event.last_time,
|
||||||
|
open_times: event.open_times,
|
||||||
|
});
|
||||||
|
operationMessage.value = "人工修订已保存,并优先于自动补充内容";
|
||||||
|
ui.showToast("事件原因修订已保存");
|
||||||
|
await showEventHistory();
|
||||||
|
} catch (error) {
|
||||||
|
ui.showToast(error instanceof Error ? error.message : "事件修订失败");
|
||||||
|
} finally { eventBusy.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showEventHistory(): Promise<void> {
|
||||||
|
if (!event.identifier.trim()) return;
|
||||||
|
try {
|
||||||
|
eventHistory.value = await operationsApi.eventHistory(
|
||||||
|
event.trade_date, event.identifier.trim(),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
ui.showToast(error instanceof Error ? error.message : "修订记录读取失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function duration(value: number | null): string {
|
||||||
|
if (value === null) return "";
|
||||||
|
return value < 1000 ? `${value} ms` : `${(value / 1000).toFixed(1)} s`;
|
||||||
|
}
|
||||||
|
|
||||||
async function save(name: string): Promise<void> {
|
async function save(name: string): Promise<void> {
|
||||||
const value = values[name]?.trim();
|
const value = values[name]?.trim();
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -97,11 +189,32 @@ onMounted(load);
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<header class="card-header"><h2>历史数据回补</h2><span class="tag">暂不可用</span></header>
|
<header class="card-header"><h2>历史数据回补</h2><span class="faint">单次最多15个交易日</span></header>
|
||||||
<div class="card-body disabled-row">
|
<form class="card-body operation-form" @submit.prevent="runBackfill">
|
||||||
<p class="muted">历史数据回补将在行情数据迁移完成后开放。</p>
|
<label class="field"><span class="field-label">开始日期</span><input v-model="backfill.start_date" class="input" type="date" required /></label>
|
||||||
<button class="btn" type="button" disabled>开始回补</button>
|
<label class="field"><span class="field-label">结束日期</span><input v-model="backfill.end_date" class="input" type="date" required /></label>
|
||||||
</div>
|
<button class="btn" type="submit" :disabled="backfilling">{{ backfilling ? "正在回补" : "开始回补" }}</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<header class="card-header"><h2>事件原因治理</h2><span class="faint">人工修订优先并保留记录</span></header>
|
||||||
|
<form class="card-body event-form" @submit.prevent="reviseEvent">
|
||||||
|
<label class="field"><span class="field-label">数据日期</span><input v-model="event.trade_date" class="input" type="date" required /></label>
|
||||||
|
<label class="field"><span class="field-label">股票代码</span><input v-model="event.identifier" class="input" placeholder="000001.SZ" required /></label>
|
||||||
|
<label class="field"><span class="field-label">事件类型</span><select v-model="event.event_type" class="select"><option value="limit_up">涨停</option><option value="broken">炸板</option><option value="limit_down">跌停</option></select></label>
|
||||||
|
<label class="field event-reason"><span class="field-label">原因</span><input v-model="event.reason" class="input" maxlength="200" required /></label>
|
||||||
|
<label class="field"><span class="field-label">首次时间</span><input v-model="event.first_time" class="input" type="time" /></label>
|
||||||
|
<label class="field"><span class="field-label">最后时间</span><input v-model="event.last_time" class="input" type="time" /></label>
|
||||||
|
<label class="field"><span class="field-label">开板次数</span><input v-model.number="event.open_times" class="input" type="number" min="0" /></label>
|
||||||
|
<div class="table-actions event-actions"><button class="btn btn-primary" type="submit" :disabled="eventBusy">保存修订</button><button class="btn" type="button" :disabled="eventBusy" @click="supplementEvents">自动补充</button><button class="btn" type="button" @click="showEventHistory">查看记录</button></div>
|
||||||
|
</form>
|
||||||
|
<div v-if="eventHistory.length" class="data-table-wrap event-history"><table class="data-table"><thead><tr><th>时间</th><th>来源</th><th>类型</th><th>原因</th><th>修订人</th></tr></thead><tbody><tr v-for="row in eventHistory" :key="String(row.id)"><td>{{ String(row.created_at).replace('T', ' ').slice(0, 19) }}</td><td>{{ row.source === 'admin' ? '人工修订' : '自动补充' }}</td><td>{{ row.event_type }}</td><td>{{ row.reason }}</td><td>{{ row.created_by_name ?? '' }}</td></tr></tbody></table></div>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<header class="card-header"><h2>后台任务状态</h2><button class="btn btn-small" type="button" @click="loadJobs">刷新状态</button></header>
|
||||||
|
<p v-if="operationMessage" class="notice">{{ operationMessage }}</p>
|
||||||
|
<div v-if="jobs.length" class="data-table-wrap operation-jobs"><table class="data-table"><thead><tr><th>任务</th><th>数据日期</th><th>状态</th><th>开始时间</th><th class="numeric">耗时</th><th class="numeric">覆盖率(%)</th><th>说明</th></tr></thead><tbody><tr v-for="job in jobs" :key="job.id"><td>{{ jobLabels[job.kind] ?? job.kind }}</td><td>{{ job.requested_date }}</td><td><span class="tag" :class="{ warning: job.status === 'failed' }">{{ statusLabels[job.status] }}</span></td><td>{{ job.started_at.replace('T', ' ').slice(0, 19) }}</td><td class="numeric">{{ duration(job.duration_ms) }}</td><td class="numeric">{{ job.coverage === null ? '' : (job.coverage * 100).toFixed(1) }}</td><td>{{ job.error_message }}</td></tr></tbody></table></div>
|
||||||
|
<p v-else class="card-body muted">暂无后台任务记录</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -19,13 +19,21 @@ function number(value: number | null, digits = 2): string {
|
|||||||
</div>
|
</div>
|
||||||
<span v-if="run" class="tag">{{ run.items.length }} 只</span>
|
<span v-if="run" class="tag">{{ run.items.length }} 只</span>
|
||||||
</header>
|
</header>
|
||||||
<div v-if="run?.status === 'data_incomplete'" class="notice notice-warning">
|
<div v-if="!run || run.status === 'pending'" class="screener-empty">
|
||||||
|
<strong>盘后选股尚未执行</strong>
|
||||||
|
<span>当日收盘行情定稿后由后台自动计算并归档。</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="run.status === 'running'" class="screener-empty">
|
||||||
|
<strong>正在计算候选结果</strong>
|
||||||
|
<span>完成后会自动保存本次结果。</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="run.status === 'data_incomplete'" class="notice notice-warning">
|
||||||
数据尚不完整:{{ run.missing_fields.join("、") }}
|
数据尚不完整:{{ run.missing_fields.join("、") }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="run?.status === 'failed'" class="notice notice-warning">
|
<div v-else-if="run.status === 'failed'" class="notice notice-warning">
|
||||||
本次计算失败,已隔离该策略,不影响其他策略。
|
本次计算失败,已隔离该策略,不影响其他策略。{{ run.error_message }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!run || run.status === 'no_signal' || !run.items.length" class="screener-empty">
|
<div v-else-if="run.status === 'no_signal' || !run.items.length" class="screener-empty">
|
||||||
<strong>暂无符合条件个股</strong>
|
<strong>暂无符合条件个股</strong>
|
||||||
<span>完整数据下无信号会保留为空,不补造候选。</span>
|
<span>完整数据下无信号会保留为空,不补造候选。</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,18 @@ const emit = defineEmits<{ track: [run: ScreenerRun, candidate: Candidate] }>();
|
|||||||
const selected = ref(props.runs[0]?.strategy_id ?? props.strategies[0]?.id ?? "");
|
const selected = ref(props.runs[0]?.strategy_id ?? props.strategies[0]?.id ?? "");
|
||||||
const run = computed(() => props.runs.find((item) => item.strategy_id === selected.value));
|
const run = computed(() => props.runs.find((item) => item.strategy_id === selected.value));
|
||||||
const strategy = computed(() => props.strategies.find((item) => item.id === (run.value?.strategy_id ?? selected.value)));
|
const strategy = computed(() => props.strategies.find((item) => item.id === (run.value?.strategy_id ?? selected.value)));
|
||||||
const hasRun = computed(() => Boolean(run.value));
|
const runState = computed<"idle" | "running" | "failed" | "completed">(() => {
|
||||||
|
if (!run.value || run.value.status === "pending") return "idle";
|
||||||
|
if (run.value.status === "running") return "running";
|
||||||
|
if (run.value.status === "failed") return "failed";
|
||||||
|
return "completed";
|
||||||
|
});
|
||||||
|
const runStateLabel = computed(() => ({
|
||||||
|
idle: "未执行",
|
||||||
|
running: "执行中",
|
||||||
|
failed: "失败",
|
||||||
|
completed: "已完成",
|
||||||
|
})[runState.value]);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.runs,
|
() => props.runs,
|
||||||
@@ -26,10 +37,13 @@ watch(
|
|||||||
<header class="stage-heading">
|
<header class="stage-heading">
|
||||||
<div><span>当前阶段自动候选</span><strong>{{ strategy?.display_name ?? "等待盘后判定" }}</strong></div>
|
<div><span>当前阶段自动候选</span><strong>{{ strategy?.display_name ?? "等待盘后判定" }}</strong></div>
|
||||||
<p>{{ strategy?.description ?? "每日收盘数据定稿后自动生成,结果允许为空。" }}</p>
|
<p>{{ strategy?.description ?? "每日收盘数据定稿后自动生成,结果允许为空。" }}</p>
|
||||||
<span class="tag">盘后自动</span>
|
<span class="tag" :class="{ warning: runState === 'failed' }">{{ runStateLabel }}</span>
|
||||||
</header>
|
</header>
|
||||||
<div class="stage-flow" aria-label="自动选股流程">
|
<div class="stage-flow" aria-label="自动选股流程">
|
||||||
<span :class="{ done: hasRun }">阶段识别</span><i></i><span :class="{ done: hasRun }">策略匹配</span><i></i><span :class="{ done: hasRun }">自动计算</span><i></i><span :class="{ done: hasRun }">结果归档</span>
|
<span :class="{ done: runState !== 'idle' }">阶段识别</span><i></i>
|
||||||
|
<span :class="{ done: runState !== 'idle' }">策略匹配</span><i></i>
|
||||||
|
<span :class="{ done: runState === 'running' || runState === 'completed' }">自动计算</span><i></i>
|
||||||
|
<span :class="{ done: runState === 'completed' }">结果归档</span>
|
||||||
</div>
|
</div>
|
||||||
<nav v-if="runs.length > 1" class="stage-run-tabs" aria-label="当日阶段策略">
|
<nav v-if="runs.length > 1" class="stage-run-tabs" aria-label="当日阶段策略">
|
||||||
<button v-for="item in runs" :key="item.id" type="button" :class="{ active: selected === item.strategy_id }" @click="selected = item.strategy_id">{{ item.strategy_name }}</button>
|
<button v-for="item in runs" :key="item.id" type="button" :class="{ active: selected === item.strategy_id }" @click="selected = item.strategy_id">{{ item.strategy_name }}</button>
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ const selected = ref(props.strategies[0]?.id ?? "");
|
|||||||
const strategy = computed(() => props.strategies.find((item) => item.id === selected.value) ?? filtered.value[0]);
|
const strategy = computed(() => props.strategies.find((item) => item.id === selected.value) ?? filtered.value[0]);
|
||||||
const run = computed(() => props.runs.find((item) => item.strategy_id === strategy.value?.id));
|
const run = computed(() => props.runs.find((item) => item.strategy_id === strategy.value?.id));
|
||||||
const regimeLabels: Record<string, string> = { ice: "冰点", repair: "修复", fermentation: "发酵", climax: "高潮", divergence: "分化", retreat: "退潮" };
|
const regimeLabels: Record<string, string> = { ice: "冰点", repair: "修复", fermentation: "发酵", climax: "高潮", divergence: "分化", retreat: "退潮" };
|
||||||
|
|
||||||
|
function statusLabel(strategyId: string): string {
|
||||||
|
const status = props.runs.find((item) => item.strategy_id === strategyId)?.status;
|
||||||
|
return ({
|
||||||
|
pending: "未执行",
|
||||||
|
running: "执行中",
|
||||||
|
completed: "有候选",
|
||||||
|
no_signal: "暂无信号",
|
||||||
|
data_incomplete: "数据不足",
|
||||||
|
failed: "计算失败",
|
||||||
|
} as Record<string, string>)[status ?? "pending"] ?? "未执行";
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -32,7 +44,7 @@ const regimeLabels: Record<string, string> = { ice: "冰点", repair: "修复",
|
|||||||
<div class="strategy-items" :class="`is-${view}`">
|
<div class="strategy-items" :class="`is-${view}`">
|
||||||
<button v-for="item in filtered" :key="item.id" type="button" :class="{ active: strategy?.id === item.id }" @click="selected = item.id">
|
<button v-for="item in filtered" :key="item.id" type="button" :class="{ active: strategy?.id === item.id }" @click="selected = item.id">
|
||||||
<strong>{{ item.display_name }}</strong><small>{{ item.formula.meta?.category }}</small>
|
<strong>{{ item.display_name }}</strong><small>{{ item.formula.meta?.category }}</small>
|
||||||
<span :class="['strategy-status', runs.find((run) => run.strategy_id === item.id)?.status]">{{ runs.find((run) => run.strategy_id === item.id)?.status === 'completed' ? '有候选' : runs.find((run) => run.strategy_id === item.id)?.status === 'data_incomplete' ? '数据不足' : '暂无信号' }}</span>
|
<span :class="['strategy-status', runs.find((run) => run.strategy_id === item.id)?.status]">{{ statusLabel(item.id) }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export type RuntimeStatus = {
|
||||||
|
state: "idle" | "running" | "ready" | "degraded";
|
||||||
|
message: string;
|
||||||
|
trade_date?: string | null;
|
||||||
|
observed_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JobRun = {
|
||||||
|
id: number;
|
||||||
|
kind: string;
|
||||||
|
requested_date: string;
|
||||||
|
trigger: string;
|
||||||
|
status: "running" | "completed" | "failed";
|
||||||
|
attempt: number;
|
||||||
|
started_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
duration_ms: number | null;
|
||||||
|
coverage: number | null;
|
||||||
|
error_message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EventRevisionInput = {
|
||||||
|
event_type: "limit_up" | "broken" | "limit_down";
|
||||||
|
reason: string;
|
||||||
|
first_time: string;
|
||||||
|
last_time: string;
|
||||||
|
open_times: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const operationsApi = {
|
||||||
|
status(): Promise<RuntimeStatus> {
|
||||||
|
return api.get("/operations/status");
|
||||||
|
},
|
||||||
|
jobs(): Promise<JobRun[]> {
|
||||||
|
return api.get("/admin/operations/jobs");
|
||||||
|
},
|
||||||
|
backfill(start_date: string, end_date: string): Promise<Record<string, unknown>> {
|
||||||
|
return api.post("/admin/operations/backfill", { start_date, end_date });
|
||||||
|
},
|
||||||
|
supplementEvents(date: string): Promise<Record<string, unknown>> {
|
||||||
|
return api.post(`/admin/operations/events/supplement?date=${encodeURIComponent(date)}`);
|
||||||
|
},
|
||||||
|
reviseEvent(
|
||||||
|
date: string,
|
||||||
|
identifier: string,
|
||||||
|
value: EventRevisionInput,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return api.put(
|
||||||
|
`/admin/operations/events/${encodeURIComponent(date)}/${encodeURIComponent(identifier)}`,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
eventHistory(date: string, identifier: string): Promise<Record<string, unknown>[]> {
|
||||||
|
return api.get(
|
||||||
|
`/admin/operations/events/${encodeURIComponent(date)}/${encodeURIComponent(identifier)}/history`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -229,11 +229,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.credential-row,
|
.credential-row,
|
||||||
.selection-grid {
|
.selection-grid,
|
||||||
|
.operation-form,
|
||||||
|
.event-form {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.event-reason,
|
||||||
|
.event-actions {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.member-editor {
|
.member-editor {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,30 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.operation-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, var(--s-200))) auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: var(--s-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
align-items: end;
|
||||||
|
gap: var(--s-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-reason,
|
||||||
|
.event-actions {
|
||||||
|
grid-column: span 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-history,
|
||||||
|
.operation-jobs {
|
||||||
|
max-height: var(--s-260);
|
||||||
|
}
|
||||||
|
|
||||||
.model-list {
|
.model-list {
|
||||||
max-height: calc(100vh - var(--s-260));
|
max-height: calc(100vh - var(--s-260));
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
const { expect, test } = require("@playwright/test");
|
||||||
|
|
||||||
|
async function authenticate(page) {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByLabel("账号名").fill("stage4admin");
|
||||||
|
await page.getByLabel("密码").fill("Stage4-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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("administrator can audit jobs, backfill and govern market events", async ({ page }) => {
|
||||||
|
const calls = [];
|
||||||
|
const job = {
|
||||||
|
id: 7,
|
||||||
|
kind: "market.refresh",
|
||||||
|
run_key: "2026-07-30:final:1",
|
||||||
|
requested_date: "2026-07-30",
|
||||||
|
trigger: "after-close",
|
||||||
|
status: "failed",
|
||||||
|
attempt: 1,
|
||||||
|
started_at: "2026-07-30T15:10:00+08:00",
|
||||||
|
finished_at: "2026-07-30T15:10:02+08:00",
|
||||||
|
duration_ms: 2000,
|
||||||
|
coverage: null,
|
||||||
|
source_set: [],
|
||||||
|
output_version: "",
|
||||||
|
payload: {},
|
||||||
|
error_code: "ProviderError",
|
||||||
|
error_message: "上游暂不可用,保留最后成功快照",
|
||||||
|
};
|
||||||
|
await page.route("**/api/admin/system/credentials", (route) => route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([
|
||||||
|
{ name: "tushare_token", configured: true, updated_at: "2026-07-30T09:00:00+08:00" },
|
||||||
|
{ name: "ifind_refresh_token", configured: true, updated_at: "2026-07-30T09:00:00+08:00" },
|
||||||
|
{ name: "ifind_access_token", configured: true, updated_at: "2026-07-30T09:00:00+08:00" },
|
||||||
|
]),
|
||||||
|
}));
|
||||||
|
await page.route("**/api/admin/operations/jobs", (route) => route.fulfill({
|
||||||
|
contentType: "application/json", body: JSON.stringify([job]),
|
||||||
|
}));
|
||||||
|
await page.route("**/api/admin/operations/backfill", async (route) => {
|
||||||
|
calls.push({ kind: "backfill", payload: route.request().postDataJSON() });
|
||||||
|
await route.fulfill({ contentType: "application/json", body: JSON.stringify({ completed: 2 }) });
|
||||||
|
});
|
||||||
|
await page.route("**/api/admin/operations/events/supplement?*", async (route) => {
|
||||||
|
calls.push({ kind: "supplement" });
|
||||||
|
await route.fulfill({ contentType: "application/json", body: JSON.stringify({ matched: 3 }) });
|
||||||
|
});
|
||||||
|
await page.route("**/api/admin/operations/events/*/*/history", (route) => route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([{
|
||||||
|
id: 2,
|
||||||
|
created_at: "2026-07-30T15:20:00+08:00",
|
||||||
|
source: "admin",
|
||||||
|
event_type: "limit_up",
|
||||||
|
reason: "人工核验原因",
|
||||||
|
created_by_name: "stage4admin",
|
||||||
|
}]),
|
||||||
|
}));
|
||||||
|
await page.route("**/api/admin/operations/events/*/*", async (route) => {
|
||||||
|
calls.push({ kind: "revision", payload: route.request().postDataJSON() });
|
||||||
|
await route.fulfill({ contentType: "application/json", body: JSON.stringify({ id: 2 }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
await authenticate(page);
|
||||||
|
await page.getByRole("button", { name: "系统管理" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "后台任务状态" })).toBeVisible();
|
||||||
|
await expect(page.getByText("上游暂不可用,保留最后成功快照")).toBeVisible();
|
||||||
|
|
||||||
|
const backfill = page.locator(".operation-form");
|
||||||
|
await backfill.getByLabel("开始日期").fill("2026-07-29");
|
||||||
|
await backfill.getByLabel("结束日期").fill("2026-07-30");
|
||||||
|
await backfill.getByRole("button", { name: "开始回补" }).click();
|
||||||
|
await expect(page.getByRole("status")).toContainText("历史数据回补已完成");
|
||||||
|
|
||||||
|
const event = page.locator(".event-form");
|
||||||
|
await event.getByLabel("数据日期").fill("2026-07-30");
|
||||||
|
await event.getByLabel("股票代码").fill("000001.SZ");
|
||||||
|
await event.getByLabel("原因").fill("人工核验原因");
|
||||||
|
await event.getByRole("button", { name: "保存修订" }).click();
|
||||||
|
await expect(page.getByText("人工核验原因", { exact: true })).toBeVisible();
|
||||||
|
await event.getByRole("button", { name: "自动补充" }).click();
|
||||||
|
await expect(page.getByRole("status")).toContainText("事件原因补充完成");
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{ kind: "backfill", payload: { start_date: "2026-07-29", end_date: "2026-07-30" } },
|
||||||
|
{
|
||||||
|
kind: "revision",
|
||||||
|
payload: {
|
||||||
|
event_type: "limit_up",
|
||||||
|
reason: "人工核验原因",
|
||||||
|
first_time: "",
|
||||||
|
last_time: "",
|
||||||
|
open_times: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ kind: "supplement" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ async function authenticate(page, username, password) {
|
|||||||
if (!(await page.locator(".sidebar").isVisible())) {
|
if (!(await page.locator(".sidebar").isVisible())) {
|
||||||
await page.getByRole("tab", { name: "注册" }).click();
|
await page.getByRole("tab", { name: "注册" }).click();
|
||||||
await page.getByRole("button", { name: "注册并登录" }).click();
|
await page.getByRole("button", { name: "注册并登录" }).click();
|
||||||
|
await expect(page.locator(".sidebar")).toBeVisible();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,3 +199,49 @@ test("nonmembers see the same screening structure in a disabled state", async ({
|
|||||||
await expect(page.getByText("因子与权重")).toBeVisible();
|
await expect(page.getByText("因子与权重")).toBeVisible();
|
||||||
await expect(page.locator(".custom-builder")).toHaveAttribute("aria-disabled", "true");
|
await expect(page.locator(".custom-builder")).toHaveAttribute("aria-disabled", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("stage workflow reports idle, running, failed and completed truthfully", async ({ page }) => {
|
||||||
|
let status = "pending";
|
||||||
|
await page.route("**/api/screener/catalog", (route) => route.fulfill({
|
||||||
|
contentType: "application/json", body: JSON.stringify(catalog),
|
||||||
|
}));
|
||||||
|
await page.route("**/api/screener?*", (route) => {
|
||||||
|
const stageRuns = status === "pending" ? [] : [run(
|
||||||
|
11,
|
||||||
|
"stage",
|
||||||
|
"冰点抗跌先手",
|
||||||
|
status,
|
||||||
|
"stage-ice",
|
||||||
|
candidate,
|
||||||
|
)];
|
||||||
|
if (stageRuns[0] && status === "failed") stageRuns[0].error_message = "因子快照损坏";
|
||||||
|
return route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ ...workspace, stage_runs: stageRuns }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await authenticate(page, "stage4admin", "Stage4-pass-123!");
|
||||||
|
await page.goto("/workspace/screener");
|
||||||
|
|
||||||
|
await expect(page.locator(".stage-heading .tag")).toHaveText("未执行");
|
||||||
|
await expect(page.locator(".stage-flow .done")).toHaveCount(0);
|
||||||
|
await expect(page.getByText("盘后选股尚未执行")).toBeVisible();
|
||||||
|
|
||||||
|
status = "running";
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator(".stage-heading .tag")).toHaveText("执行中");
|
||||||
|
await expect(page.locator(".stage-flow .done")).toHaveCount(3);
|
||||||
|
await expect(page.getByText("正在计算候选结果")).toBeVisible();
|
||||||
|
|
||||||
|
status = "failed";
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator(".stage-heading .tag")).toHaveText("失败");
|
||||||
|
await expect(page.locator(".stage-flow .done")).toHaveCount(2);
|
||||||
|
await expect(page.locator(".screener-results")).toContainText("因子快照损坏");
|
||||||
|
|
||||||
|
status = "completed";
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator(".stage-heading .tag")).toHaveText("已完成");
|
||||||
|
await expect(page.locator(".stage-flow .done")).toHaveCount(4);
|
||||||
|
await expect(page.locator(".screener-results")).toContainText("平安银行");
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def test_status_reads_an_existing_schema_without_mutating_history(
|
|||||||
|
|
||||||
assert main(["status"]) == 0
|
assert main(["status"]) == 0
|
||||||
|
|
||||||
assert capsys.readouterr().out.strip() == "available=true schema_version=10"
|
assert capsys.readouterr().out.strip() == "available=true schema_version=11"
|
||||||
|
|
||||||
|
|
||||||
def test_downgrade_requires_explicit_confirmation(tmp_path, monkeypatch) -> None:
|
def test_downgrade_requires_explicit_confirmation(tmp_path, monkeypatch) -> None:
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
|||||||
database = Database(tmp_path / "app.db")
|
database = Database(tmp_path / "app.db")
|
||||||
runner = MigrationRunner(database)
|
runner = MigrationRunner(database)
|
||||||
|
|
||||||
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
|
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)
|
||||||
assert {
|
assert {
|
||||||
"users",
|
"users",
|
||||||
"memberships",
|
"memberships",
|
||||||
@@ -144,9 +144,12 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
|||||||
"trade_entries",
|
"trade_entries",
|
||||||
"alerts",
|
"alerts",
|
||||||
"review_assistant_messages",
|
"review_assistant_messages",
|
||||||
|
"job_runs",
|
||||||
|
"market_event_revisions",
|
||||||
} <= table_names(database)
|
} <= table_names(database)
|
||||||
|
|
||||||
assert runner.downgrade(MIGRATIONS, target_version=0) == (
|
assert runner.downgrade(MIGRATIONS, target_version=0) == (
|
||||||
|
11,
|
||||||
10,
|
10,
|
||||||
9,
|
9,
|
||||||
8,
|
8,
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
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.repository import MarketRepository
|
||||||
|
from backend.database.connection import Database
|
||||||
|
from backend.database.migrations import MIGRATIONS, MigrationRunner
|
||||||
|
from backend.features.market.events import MarketEventService, apply_event_revisions
|
||||||
|
from backend.features.market.snapshot import build_realtime_inputs, build_snapshot
|
||||||
|
from backend.jobs.repository import JobRepository
|
||||||
|
from backend.jobs.service import JobAlreadyRunning, JobService
|
||||||
|
from tests.support import run_scenario
|
||||||
|
from tests.test_accounts import (
|
||||||
|
ADMIN_PASSWORD,
|
||||||
|
USER_PASSWORD,
|
||||||
|
register,
|
||||||
|
use_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_service_records_success_failure_attempts_and_exclusion(tmp_path) -> None:
|
||||||
|
database = Database(tmp_path / "jobs.db")
|
||||||
|
MigrationRunner(database).upgrade(MIGRATIONS)
|
||||||
|
repository = JobRepository()
|
||||||
|
jobs = JobService(database, repository)
|
||||||
|
|
||||||
|
result = jobs.execute(
|
||||||
|
kind="market.refresh",
|
||||||
|
run_key="2026-07-30:manual",
|
||||||
|
requested_date="2026-07-30",
|
||||||
|
trigger="administrator",
|
||||||
|
operation=lambda: {
|
||||||
|
"coverage": 0.99,
|
||||||
|
"source_set": ["tushare", "local"],
|
||||||
|
"output_version": "market-summary-v1",
|
||||||
|
},
|
||||||
|
stale_after_seconds=120,
|
||||||
|
)
|
||||||
|
assert result["coverage"] == 0.99
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="network unavailable"):
|
||||||
|
jobs.execute(
|
||||||
|
kind="market.refresh",
|
||||||
|
run_key="2026-07-30:retry",
|
||||||
|
requested_date="2026-07-30",
|
||||||
|
trigger="after-close",
|
||||||
|
operation=lambda: (_ for _ in ()).throw(RuntimeError("network unavailable")),
|
||||||
|
stale_after_seconds=120,
|
||||||
|
)
|
||||||
|
latest = jobs.latest()
|
||||||
|
assert [row["status"] for row in latest[:2]] == ["failed", "completed"]
|
||||||
|
assert latest[0]["error_message"] == "network unavailable"
|
||||||
|
assert latest[1]["source_set"] == ["tushare", "local"]
|
||||||
|
failure_finished = datetime.fromisoformat(latest[0]["finished_at"])
|
||||||
|
assert not jobs.ready_for_schedule(
|
||||||
|
"market.refresh",
|
||||||
|
now=failure_finished + timedelta(seconds=59),
|
||||||
|
completed_after_seconds=8,
|
||||||
|
failed_after_seconds=60,
|
||||||
|
)
|
||||||
|
assert jobs.ready_for_schedule(
|
||||||
|
"market.refresh",
|
||||||
|
now=failure_finished + timedelta(seconds=60),
|
||||||
|
completed_after_seconds=8,
|
||||||
|
failed_after_seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
with database.transaction() as connection:
|
||||||
|
repository.begin(
|
||||||
|
connection,
|
||||||
|
kind="auction.collect",
|
||||||
|
run_key="active",
|
||||||
|
requested_date="2026-07-30",
|
||||||
|
trigger="auction-poll",
|
||||||
|
started_at=datetime.now(SHANGHAI),
|
||||||
|
stale_after_seconds=60,
|
||||||
|
)
|
||||||
|
with pytest.raises(JobAlreadyRunning):
|
||||||
|
jobs.execute(
|
||||||
|
kind="auction.collect",
|
||||||
|
run_key="second",
|
||||||
|
requested_date="2026-07-30",
|
||||||
|
trigger="auction-poll",
|
||||||
|
operation=lambda: {},
|
||||||
|
stale_after_seconds=60,
|
||||||
|
)
|
||||||
|
assert not jobs.ready_for_schedule(
|
||||||
|
"auction.collect",
|
||||||
|
now=datetime.now(SHANGHAI) + timedelta(hours=1),
|
||||||
|
completed_after_seconds=8,
|
||||||
|
failed_after_seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EventGateway:
|
||||||
|
reason = "银行板块走强"
|
||||||
|
|
||||||
|
def event_reasons(self, trade_date: str) -> ProviderResult:
|
||||||
|
return ProviderResult(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"event_type": "limit_up",
|
||||||
|
"identifier": "000001.SZ",
|
||||||
|
"reason": self.reason,
|
||||||
|
"first_time": "09:35",
|
||||||
|
"last_time": "14:20",
|
||||||
|
"open_times": 1,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
metadata(SnapshotState.FINAL),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_event_revision_wins_and_history_is_preserved(tmp_path) -> None:
|
||||||
|
database = Database(tmp_path / "events.db")
|
||||||
|
MigrationRunner(database).upgrade(MIGRATIONS)
|
||||||
|
repository = MarketRepository()
|
||||||
|
payload = {
|
||||||
|
"limits": [{"identifier": "000001.SZ", "code": "000001", "reason": ""}],
|
||||||
|
"broken": [],
|
||||||
|
"down_limits": [],
|
||||||
|
}
|
||||||
|
with database.transaction() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (
|
||||||
|
username, username_key, password_hash, is_admin, created_at, updated_at
|
||||||
|
) VALUES ('admin', 'admin', 'hash', 1, ?, ?)
|
||||||
|
""",
|
||||||
|
("2026-07-30T09:00:00+08:00", "2026-07-30T09:00:00+08:00"),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO market_summaries (
|
||||||
|
trade_date, observed_at, state, source, coverage, payload_json, created_at
|
||||||
|
) VALUES ('2026-07-30', '2026-07-30T15:00:00+08:00', 'final',
|
||||||
|
'tushare', 1, ?, '2026-07-30T15:05:00+08:00')
|
||||||
|
""",
|
||||||
|
(json.dumps(payload, ensure_ascii=False),),
|
||||||
|
)
|
||||||
|
gateway = EventGateway()
|
||||||
|
events = MarketEventService(database, repository, gateway)
|
||||||
|
assert events.supplement("2026-07-30")["updated"] == 1
|
||||||
|
events.revise(
|
||||||
|
trade_date="2026-07-30",
|
||||||
|
identifier="000001.SZ",
|
||||||
|
event_type="limit_up",
|
||||||
|
reason="管理员核验原因",
|
||||||
|
first_time="09:36",
|
||||||
|
last_time="14:21",
|
||||||
|
open_times=2,
|
||||||
|
user_id=1,
|
||||||
|
)
|
||||||
|
gateway.reason = "后续自动结果"
|
||||||
|
assert events.supplement("2026-07-30")["updated"] == 0
|
||||||
|
with database.read() as connection:
|
||||||
|
revisions = repository.event_revisions(connection, "2026-07-30")
|
||||||
|
resolved = apply_event_revisions(payload, revisions)
|
||||||
|
assert resolved["limits"][0]["reason"] == "管理员核验原因"
|
||||||
|
assert resolved["limits"][0]["reason_source"] == "admin"
|
||||||
|
history = events.history("2026-07-30", "000001.SZ")
|
||||||
|
assert [row["source"] for row in history] == ["admin", "ifind"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_realtime_snapshot_uses_official_limits_and_yuan_amounts() -> None:
|
||||||
|
inputs = {
|
||||||
|
"daily": ProviderResult(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"ts_code": "000001.SZ",
|
||||||
|
"open": 10.2,
|
||||||
|
"high": 11,
|
||||||
|
"low": 10.1,
|
||||||
|
"close": 11,
|
||||||
|
"pre_close": 10,
|
||||||
|
"pct_chg": 10,
|
||||||
|
"amount": 100_000_000,
|
||||||
|
"amount_unit": "yuan",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts_code": "000002.SZ",
|
||||||
|
"open": 10,
|
||||||
|
"high": 11,
|
||||||
|
"low": 9.9,
|
||||||
|
"close": 10.5,
|
||||||
|
"pre_close": 10,
|
||||||
|
"pct_chg": 5,
|
||||||
|
"amount": 200_000_000,
|
||||||
|
"amount_unit": "yuan",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
metadata(SnapshotState.REALTIME),
|
||||||
|
),
|
||||||
|
"price_limits": ProviderResult(
|
||||||
|
(
|
||||||
|
{"ts_code": "000001.SZ", "up_limit": 11, "down_limit": 9},
|
||||||
|
{"ts_code": "000002.SZ", "up_limit": 11, "down_limit": 9},
|
||||||
|
),
|
||||||
|
metadata(SnapshotState.REALTIME),
|
||||||
|
),
|
||||||
|
"previous_limit_up": ProviderResult(
|
||||||
|
({"ts_code": "000001.SZ", "limit_times": 2},),
|
||||||
|
metadata(SnapshotState.FINAL),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
directory = {
|
||||||
|
"000001.SZ": {"name": "ST样本", "sector": "银行"},
|
||||||
|
"000002.SZ": {"name": "炸板样本", "sector": "银行"},
|
||||||
|
}
|
||||||
|
snapshot = build_snapshot(
|
||||||
|
"2026-07-30",
|
||||||
|
"2026-07-29",
|
||||||
|
build_realtime_inputs(inputs, directory),
|
||||||
|
)
|
||||||
|
assert snapshot["overview"]["amount"] == 300_000_000
|
||||||
|
assert snapshot["limits"][0]["streak"] == 3
|
||||||
|
assert snapshot["limits"][0]["amount"] == 100_000_000
|
||||||
|
assert snapshot["broken"][0]["code"] == "000002"
|
||||||
|
|
||||||
|
|
||||||
|
def test_operations_status_and_admin_audit_permissions(tmp_path) -> None:
|
||||||
|
application = create_application(Settings.for_test(tmp_path))
|
||||||
|
|
||||||
|
async def scenario(client: httpx.AsyncClient) -> None:
|
||||||
|
_, admin = await register(client, "operations-admin", ADMIN_PASSWORD)
|
||||||
|
client.cookies.clear()
|
||||||
|
_, regular = await register(client, "operations-user", USER_PASSWORD)
|
||||||
|
status = await client.get("/api/operations/status")
|
||||||
|
assert status.status_code == 200
|
||||||
|
assert status.json()["state"] == "degraded"
|
||||||
|
assert (await client.get("/api/admin/operations/jobs")).status_code == 403
|
||||||
|
use_session(client, admin)
|
||||||
|
jobs = await client.get("/api/admin/operations/jobs")
|
||||||
|
assert jobs.status_code == 200
|
||||||
|
assert jobs.json() == []
|
||||||
|
|
||||||
|
run_scenario(application, scenario)
|
||||||
|
|
||||||
|
|
||||||
|
def metadata(state: SnapshotState) -> ObservationMetadata:
|
||||||
|
return ObservationMetadata(
|
||||||
|
source=DataSource.TUSHARE,
|
||||||
|
observed_at=datetime(2026, 7, 30, 10, 0, tzinfo=SHANGHAI),
|
||||||
|
unit="mixed",
|
||||||
|
adjustment="not_applicable",
|
||||||
|
freshness_seconds=0,
|
||||||
|
coverage=1,
|
||||||
|
state=state,
|
||||||
|
usage=DataUsage.CALCULATION,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user