diff --git a/backend/app/submission.py b/backend/app/submission.py index 74969b8..d170583 100644 --- a/backend/app/submission.py +++ b/backend/app/submission.py @@ -359,4 +359,7 @@ async def run_check(runner, job_id, payload): setattr(alpha, key, value) db.add(JobItem(job_id=job_id, alpha_id=alpha_id)) job.processed = 1 - job.checkpoint = {"alpha_id": alpha_id, "phase": "checked", "checked_at": now().isoformat()} + job.checkpoint = { + "alpha_id": alpha_id, "phase": "checked", "checked_at": now().isoformat(), + "review_snapshot": fingerprint(source(alpha.raw)), + } diff --git a/backend/tests/test_submission_repeat.py b/backend/tests/test_submission_repeat.py new file mode 100644 index 0000000..ba05b27 --- /dev/null +++ b/backend/tests/test_submission_repeat.py @@ -0,0 +1,23 @@ +"""A completed check can be repeated using its resulting review snapshot.""" + +from tests.test_submission import FIELDS, Description, setup + + +async def test_repeat_check_uses_resulting_snapshot_without_repatch(app, logged_in): + platform = await setup(app) + url = "/api/v1/alphas/alpha1/submission" + state = (await logged_in.get(url)).json() + job_ids = [] + for _ in range(2): + response = await logged_in.post("/api/v1/alphas/alpha1/submission-check", json={ + "snapshot": state["snapshot"], "descriptions": {"regular": Description(**FIELDS).text()}, + }) + assert response.status_code == 202 + job_ids.append(response.json()["id"]) + await app.state.runner.execute(job_ids[-1]) + state = (await logged_in.get(url)).json() + assert state["job"]["status"] == "completed" + assert state["job"]["checkpoint"]["review_snapshot"] == state["snapshot"] + assert job_ids[0] != job_ids[1] + assert len(platform.patches) == 1 + assert platform.calls.count(("GET", "/alphas/alpha1/check")) == 2 diff --git a/frontend/src/alphaMetrics.ts b/frontend/src/alphaMetrics.ts new file mode 100644 index 0000000..58f72df --- /dev/null +++ b/frontend/src/alphaMetrics.ts @@ -0,0 +1,12 @@ +/** Format the five headline Alpha metrics for display; stored/filter values stay raw. + * Unsupported fields return undefined so callers can retain their existing format. + */ +export function formatAlphaMetric( + key: string, + value: unknown, +): string | undefined { + const percent = key === "turnover" || key === "returns" || key === "drawdown"; + if (!percent && key !== "sharpe" && key !== "fitness") return undefined; + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + return percent ? `${(value * 100).toFixed(2)}%` : value.toFixed(2); +} diff --git a/frontend/src/components/AlphaDetail.tsx b/frontend/src/components/AlphaDetail.tsx index b06b4bb..fd0b3b9 100644 --- a/frontend/src/components/AlphaDetail.tsx +++ b/frontend/src/components/AlphaDetail.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { formatAlphaMetric } from "../alphaMetrics"; import { Banner, Button, @@ -464,7 +465,10 @@ function MetricTable({

{title}

{entries.length ? ( ({ key, value }))} + data={entries.map(([key, value]) => ({ + key, + value: formatAlphaMetric(key, value) ?? value, + }))} /> ) : (

未提供

diff --git a/frontend/src/components/SubmissionPanel.tsx b/frontend/src/components/SubmissionPanel.tsx index 4629f0f..9d6e506 100644 --- a/frontend/src/components/SubmissionPanel.tsx +++ b/frontend/src/components/SubmissionPanel.tsx @@ -34,6 +34,7 @@ export function SubmissionPanel({ const [busy, setBusy] = useState(""); const [error, setError] = useState(""); const dirty = useRef(false); + const submittedJob = useRef(null); const mounted = useRef(true); useEffect(() => { mounted.current = true; @@ -47,6 +48,16 @@ export function SubmissionPanel({ .then((value) => { if (!active) return; setData(value); + // Rebase only our completed writeback and its exact resulting snapshot. + // A different expression/settings/description must still require review. + if ( + value.job?.id === submittedJob.current && + value.job?.status === "completed" && + value.job.checkpoint.review_snapshot === value.snapshot + ) { + setSnapshot(value.snapshot); + submittedJob.current = null; + } if (!dirty.current) { setDraft(value.descriptions); setSnapshot(value.snapshot); @@ -89,6 +100,7 @@ export function SubmissionPanel({ if (!mounted.current) return; // Keep the reviewed draft visible while the durable task runs. dirty.current = true; + submittedJob.current = job.id; setData((current) => (current ? { ...current, job } : current)); onTask(); } catch (e) { @@ -105,14 +117,14 @@ export function SubmissionPanel({

Description 与提交检查

- AI 一次生成完整 Description,可在下方统一修改。写回并检查会更新 BRAIN 的 + AI 一次生成完整 Description,可在下方统一修改。平台检查会更新 BRAIN 的 Description,随后获取平台提交检查结果,不会正式提交 Alpha。

{error && } {!data.can_check && ( )} {!data.can_generate && ( @@ -121,6 +133,17 @@ export function SubmissionPanel({

)}
+ -
{data.job && ( diff --git a/frontend/src/pages/AlphaPage.tsx b/frontend/src/pages/AlphaPage.tsx index e3f1def..dd781f7 100644 --- a/frontend/src/pages/AlphaPage.tsx +++ b/frontend/src/pages/AlphaPage.tsx @@ -27,6 +27,7 @@ import { IconRefresh, } from "@douyinfe/semi-icons"; import "./AlphaPage.css"; +import { formatAlphaMetric } from "../alphaMetrics"; import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface"; import { api, @@ -84,8 +85,10 @@ function metricColor(key: string, value: unknown): string | undefined { return "var(--semi-color-danger)"; } -/** Convert Margin only for display; filtering and sorting still use raw values. */ +/** Format metrics only for display; filtering and sorting still use raw values. */ function formatMetric(key: string, value: unknown): string { + const headline = formatAlphaMetric(key, value); + if (headline !== undefined) return headline; if (key !== "margin") return formatNumber(value, 3); return typeof value === "number" && Number.isFinite(value) ? `${formatNumber(value * 10000, 2)}bps` diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c4d2e57..b39bdb1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -138,6 +138,7 @@ export type Job = { alpha_ids?: string[]; }; checkpoint: { + review_snapshot?: string; date?: string; dataset_id?: string; datasets_completed?: number; diff --git a/frontend/tests/submission-repeat.spec.ts b/frontend/tests/submission-repeat.spec.ts new file mode 100644 index 0000000..92d2d0d --- /dev/null +++ b/frontend/tests/submission-repeat.spec.ts @@ -0,0 +1,121 @@ +import { expect, test } from "@playwright/test"; +import type { Job } from "../src/types"; + +test("platform check can repeat after its own description writeback", async ({ + page, +}) => { + let snapshot = "a".repeat(64); + let descriptions = { regular: "" }; + let job: Job | null = null; + const submitted: string[] = []; + await page.route(/\/api\/v1\/sync-jobs$/, (route) => + route.fulfill({ json: job ? [job] : [] }), + ); + await page.route(/\/api\/v1\/alphas\/[^/]+\/submission$/, (route) => + route.fulfill({ + json: { + snapshot, + descriptions, + sections: { + regular: { code: "rank(close)", description: descriptions.regular }, + }, + model: "test-model", + can_generate: true, + can_check: true, + job, + }, + }), + ); + await page.route( + /\/api\/v1\/alphas\/[^/]+\/submission-check$/, + async (route) => { + const body = route.request().postDataJSON(); + expect(body.snapshot).toBe(snapshot); + submitted.push(body.snapshot); + descriptions = body.descriptions; + snapshot = String(submitted.length).repeat(64); + job = { + id: `repeat-${submitted.length}`, + kind: "submission_check", + status: "completed", + processed: 1, + failed: 0, + total: 1, + error: null, + next_retry_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + payload: {}, + checkpoint: { phase: "checked", review_snapshot: snapshot }, + }; + await route.fulfill({ status: 202, json: { ...job, status: "queued" } }); + }, + ); + await page.goto("/#alphas"); + await page.getByLabel("密码", { exact: true }).fill("browser-test-password"); + await page.getByRole("button", { name: "进入工作空间" }).click(); + await expect( + page.getByRole("tab", { name: "待提交", exact: true }), + ).toBeVisible(); + const headers = { "X-WQ-Request": "1" }; + await page.request.put("/api/v1/account/credentials", { + headers, + data: { email: "test@example.com", password: "synthetic-password" }, + }); + await page.request.post("/api/v1/account/connect", { headers }); + await expect + .poll( + async () => + (await (await page.request.get("/api/v1/account")).json()) + .connection_status, + ) + .toBe("connected"); + const imported = await ( + await page.request.post("/api/v1/sync-jobs", { + headers, + data: { kind: "alpha_refresh", alpha_ids: ["TEST0004"] }, + }) + ).json(); + await expect + .poll( + async () => + ( + await ( + await page.request.get(`/api/v1/sync-jobs/${imported.id}`) + ).json() + ).status, + ) + .toBe("completed"); + await page.reload(); + await page.locator(".alpha-link").first().click(); + const sheet = page.locator(".alpha-detail"); + await sheet.getByRole("tab", { name: "相关性检查", exact: true }).click(); + const load = sheet.getByRole("button", { name: "载入最新描述", exact: true }); + const generate = sheet.getByRole("button", { + name: "AI 生成 Description", + exact: true, + }); + await expect(load).toBeVisible(); + expect((await load.boundingBox())!.x).toBeLessThan( + (await generate.boundingBox())!.x, + ); + const draft = sheet.getByRole("textbox", { + name: "regular Description", + exact: true, + }); + await draft.fill("A reviewed description for repeat checking."); + const check = sheet.getByRole("button", { name: "平台检查", exact: true }); + for (let i = 1; i <= 2; i++) { + await expect(check).toBeEnabled(); + await check.click(); + await expect.poll(() => submitted.length).toBe(i); + await expect(page.locator(".job-panel")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.locator(".job-panel")).not.toBeVisible(); + await expect(check).toBeEnabled(); + await expect(draft).toHaveValue( + "A reviewed description for repeat checking.", + ); + } + expect(submitted).toEqual(["a".repeat(64), "1".repeat(64)]); +});