rebuild(runtime): govern market operations and job truth
This commit is contained in:
@@ -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())) {
|
||||
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("平安银行");
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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