rebuild(runtime): govern market operations and job truth

This commit is contained in:
leefer
2026-07-30 10:14:29 +08:00
parent 4fc8691eee
commit d8f0dd930c
39 changed files with 2224 additions and 79 deletions
+266
View File
@@ -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,
)