rebuild(runtime): govern market operations and job truth
This commit is contained in:
@@ -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