fix: allow repeated platform checks and unify Alpha metric formatting
Deploy production / deploy (push) Successful in 54s
Deploy production / deploy (push) Successful in 54s
This commit is contained in:
@@ -359,4 +359,7 @@ async def run_check(runner, job_id, payload):
|
|||||||
setattr(alpha, key, value)
|
setattr(alpha, key, value)
|
||||||
db.add(JobItem(job_id=job_id, alpha_id=alpha_id))
|
db.add(JobItem(job_id=job_id, alpha_id=alpha_id))
|
||||||
job.processed = 1
|
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)),
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { formatAlphaMetric } from "../alphaMetrics";
|
||||||
import {
|
import {
|
||||||
Banner,
|
Banner,
|
||||||
Button,
|
Button,
|
||||||
@@ -464,7 +465,10 @@ function MetricTable({
|
|||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
{entries.length ? (
|
{entries.length ? (
|
||||||
<DetailFieldGrid
|
<DetailFieldGrid
|
||||||
data={entries.map(([key, value]) => ({ key, value }))}
|
data={entries.map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: formatAlphaMetric(key, value) ?? value,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="muted">未提供</p>
|
<p className="muted">未提供</p>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function SubmissionPanel({
|
|||||||
const [busy, setBusy] = useState("");
|
const [busy, setBusy] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const dirty = useRef(false);
|
const dirty = useRef(false);
|
||||||
|
const submittedJob = useRef<string | null>(null);
|
||||||
const mounted = useRef(true);
|
const mounted = useRef(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mounted.current = true;
|
mounted.current = true;
|
||||||
@@ -47,6 +48,16 @@ export function SubmissionPanel({
|
|||||||
.then((value) => {
|
.then((value) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setData(value);
|
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) {
|
if (!dirty.current) {
|
||||||
setDraft(value.descriptions);
|
setDraft(value.descriptions);
|
||||||
setSnapshot(value.snapshot);
|
setSnapshot(value.snapshot);
|
||||||
@@ -89,6 +100,7 @@ export function SubmissionPanel({
|
|||||||
if (!mounted.current) return;
|
if (!mounted.current) return;
|
||||||
// Keep the reviewed draft visible while the durable task runs.
|
// Keep the reviewed draft visible while the durable task runs.
|
||||||
dirty.current = true;
|
dirty.current = true;
|
||||||
|
submittedJob.current = job.id;
|
||||||
setData((current) => (current ? { ...current, job } : current));
|
setData((current) => (current ? { ...current, job } : current));
|
||||||
onTask();
|
onTask();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -105,14 +117,14 @@ export function SubmissionPanel({
|
|||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<h3>Description 与提交检查</h3>
|
<h3>Description 与提交检查</h3>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
AI 一次生成完整 Description,可在下方统一修改。写回并检查会更新 BRAIN 的
|
AI 一次生成完整 Description,可在下方统一修改。平台检查会更新 BRAIN 的
|
||||||
Description,随后获取平台提交检查结果,不会正式提交 Alpha。
|
Description,随后获取平台提交检查结果,不会正式提交 Alpha。
|
||||||
</p>
|
</p>
|
||||||
{error && <Banner type="danger" description={error} />}
|
{error && <Banner type="danger" description={error} />}
|
||||||
{!data.can_check && (
|
{!data.can_check && (
|
||||||
<Banner
|
<Banner
|
||||||
type="info"
|
type="info"
|
||||||
description="写回并检查需要待提交 Alpha。有本地比较基准时,需取得有效、完整且低于阈值的自相关结果;没有本地基准时可直接继续平台检查。"
|
description="平台检查需要待提交 Alpha。有本地比较基准时,需取得有效、完整且低于阈值的自相关结果;没有本地基准时可直接继续平台检查。"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!data.can_generate && (
|
{!data.can_generate && (
|
||||||
@@ -121,6 +133,17 @@ export function SubmissionPanel({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="inline-actions">
|
<div className="inline-actions">
|
||||||
|
<Button
|
||||||
|
disabled={!!busy || running}
|
||||||
|
onClick={() => {
|
||||||
|
dirty.current = false;
|
||||||
|
setDraft(data.descriptions);
|
||||||
|
setSnapshot(data.snapshot);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
载入最新描述
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void generate()}
|
onClick={() => void generate()}
|
||||||
loading={busy === "generate"}
|
loading={busy === "generate"}
|
||||||
@@ -181,18 +204,7 @@ export function SubmissionPanel({
|
|||||||
disabled={!!busy || running || conflict || !data.can_check}
|
disabled={!!busy || running || conflict || !data.can_check}
|
||||||
onClick={() => void check()}
|
onClick={() => void check()}
|
||||||
>
|
>
|
||||||
写回 Description 并检查
|
平台检查
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={!!busy || running}
|
|
||||||
onClick={() => {
|
|
||||||
dirty.current = false;
|
|
||||||
setDraft(data.descriptions);
|
|
||||||
setSnapshot(data.snapshot);
|
|
||||||
setError("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
载入最新描述
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{data.job && (
|
{data.job && (
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
IconRefresh,
|
IconRefresh,
|
||||||
} from "@douyinfe/semi-icons";
|
} from "@douyinfe/semi-icons";
|
||||||
import "./AlphaPage.css";
|
import "./AlphaPage.css";
|
||||||
|
import { formatAlphaMetric } from "../alphaMetrics";
|
||||||
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
|
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
@@ -84,8 +85,10 @@ function metricColor(key: string, value: unknown): string | undefined {
|
|||||||
return "var(--semi-color-danger)";
|
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 {
|
function formatMetric(key: string, value: unknown): string {
|
||||||
|
const headline = formatAlphaMetric(key, value);
|
||||||
|
if (headline !== undefined) return headline;
|
||||||
if (key !== "margin") return formatNumber(value, 3);
|
if (key !== "margin") return formatNumber(value, 3);
|
||||||
return typeof value === "number" && Number.isFinite(value)
|
return typeof value === "number" && Number.isFinite(value)
|
||||||
? `${formatNumber(value * 10000, 2)}bps`
|
? `${formatNumber(value * 10000, 2)}bps`
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export type Job = {
|
|||||||
alpha_ids?: string[];
|
alpha_ids?: string[];
|
||||||
};
|
};
|
||||||
checkpoint: {
|
checkpoint: {
|
||||||
|
review_snapshot?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
dataset_id?: string;
|
dataset_id?: string;
|
||||||
datasets_completed?: number;
|
datasets_completed?: number;
|
||||||
|
|||||||
@@ -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)]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user