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
+104
View File
@@ -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" },
]);
});
+47
View File
@@ -15,6 +15,7 @@ async function authenticate(page, username, password) {
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { 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.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("平安银行");
});
+1 -1
View File
@@ -25,7 +25,7 @@ def test_status_reads_an_existing_schema_without_mutating_history(
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:
+4 -1
View File
@@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
database = Database(tmp_path / "app.db")
runner = MigrationRunner(database)
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)
assert {
"users",
"memberships",
@@ -144,9 +144,12 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
"trade_entries",
"alerts",
"review_assistant_messages",
"job_runs",
"market_event_revisions",
} <= table_names(database)
assert runner.downgrade(MIGRATIONS, target_version=0) == (
11,
10,
9,
8,
+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,
)